diff --git a/CHANGELOG.md b/CHANGELOG.md index 8727123a..30fbf64a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [Unreleased] + +### Added + +- Service account authentication support via `ServiceAccountCredentials` class +- `Tracker.new()`, `Events.new()`, `Consumer.new()`, and `BufferedConsumer.new()` now accept `credentials:` parameter +- Credentials are passed to feature flags providers (local and remote) for authenticated API access +- Authentication validation: `import()` now raises `ArgumentError` when called without either `api_key` or `credentials` +- Comprehensive test coverage for credential handling and security in `spec/mixpanel-ruby/credentials_spec.rb` and `spec/mixpanel-ruby/credentials_security_spec.rb` + +### Changed + +- When using service account credentials, pass `nil` as the first parameter to `import()`: `tracker.import(nil, distinct_id, event, properties)` instead of passing an API key +- Credentials are stored as instance variables and used only for HTTP Basic Auth headers, never serialized to JSON payloads + ## [v3.1.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.1.0) (2026-05-13) Initial entry for the standardized release process. See `Readme.rdoc` for prior version history. diff --git a/Readme.rdoc b/Readme.rdoc index f2b39121..0db04b45 100644 --- a/Readme.rdoc +++ b/Readme.rdoc @@ -29,6 +29,69 @@ The primary class you will use to track events is Mixpanel::Tracker. An instance Mixpanel::Tracker is enough to send events directly to \Mixpanel, and get you integrated right away. +== Service Account Authentication + +Service accounts provide secure server-to-server authentication and are recommended over +API keys for import operations and feature flags. + +=== Import with Service Account (Recommended) + + require 'mixpanel-ruby' + + # Create service account credentials + credentials = Mixpanel::ServiceAccountCredentials.new( + 'your-service-account-username', + 'your-service-account-secret', + 'your-project-id' + ) + + # Pass credentials to tracker constructor + # Credentials are stored securely and never serialized to JSON + tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + + # Import historical events using import_events (recommended) + tracker.import_events('User1', 'Past Event', { + 'time' => 1369353600, + 'Source' => 'Import' + }) + + # Alternative: use import() with nil as first parameter + tracker.import(nil, 'User1', 'Past Event', { + 'time' => 1369353600, + 'Source' => 'Import' + }) + +=== Import with API Key (Legacy) + + # Legacy approach with API key string + tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + tracker.import("API_KEY", 'User1', 'Past Event', { + 'time' => 1369353600, + 'Source' => 'Import' + }) + +=== Feature Flags with Service Account + + require 'mixpanel-ruby' + + credentials = Mixpanel::ServiceAccountCredentials.new( + 'your-service-account-username', + 'your-service-account-secret', + 'your-project-id' + ) + + # Pass credentials directly to the tracker + tracker = Mixpanel::Tracker.new( + YOUR_MIXPANEL_TOKEN, + nil, + credentials: credentials, + local_flags_config: {}, + remote_flags_config: {} + ) + + # Use feature flags + is_enabled = tracker.remote_flags.is_enabled?('my-flag', { 'distinct_id' => 'User1' }) + == Additional Information For more information please visit: diff --git a/lib/mixpanel-ruby.rb b/lib/mixpanel-ruby.rb index 79df69ca..4f27b514 100644 --- a/lib/mixpanel-ruby.rb +++ b/lib/mixpanel-ruby.rb @@ -1,6 +1,7 @@ require 'mixpanel-ruby/consumer.rb' require 'mixpanel-ruby/tracker.rb' require 'mixpanel-ruby/version.rb' +require 'mixpanel-ruby/credentials.rb' require 'mixpanel-ruby/flags/utils.rb' require 'mixpanel-ruby/flags/types.rb' require 'mixpanel-ruby/flags/flags_provider.rb' diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index 3078f662..10d301c9 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -40,6 +40,11 @@ def self.config_http(&block) # @kestrel.set(ANALYTICS_QUEUE, [type, message].to_json) # end # + # IMPORTANT SECURITY NOTE: Always pass credentials to the Consumer or Tracker + # constructor (credentials: ...). Credentials are stored as instance variables + # and used only for HTTP Basic Auth headers - they never appear in message JSON, + # preventing accidental credential leakage in logs or queue storage. + # # You can also instantiate the library consumers yourself, and use # them wherever you would like. For example, the working that # consumes the above queue might work like this: @@ -58,14 +63,19 @@ class Consumer # they will be used instead of the default Mixpanel endpoints. # This can be useful for proxying, debugging, or if you prefer # not to use SSL for your events. + # + # @param credentials [ServiceAccountCredentials, nil] Service account credentials for authentication. + # Credentials are only used for the /import endpoint and feature flags. def initialize(events_endpoint=nil, update_endpoint=nil, groups_endpoint=nil, - import_endpoint=nil) + import_endpoint=nil, + credentials: nil) @events_endpoint = events_endpoint || 'https://api.mixpanel.com/track' @update_endpoint = update_endpoint || 'https://api.mixpanel.com/engage' @groups_endpoint = groups_endpoint || 'https://api.mixpanel.com/groups' @import_endpoint = import_endpoint || 'https://api.mixpanel.com/import' + @credentials = credentials end # Send the given string message to Mixpanel. Type should be @@ -85,13 +95,26 @@ def send!(type, message) decoded_message = JSON.load(message) api_key = decoded_message["api_key"] + data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '') form_data = {"data" => data, "verbose" => 1} - form_data.merge!("api_key" => api_key) if api_key + + # Only add api_key to form data if using legacy API key (not service account credentials) + # Service account credentials use HTTP Basic Auth instead + if api_key && !@credentials + form_data.merge!("api_key" => api_key) + end begin - response_code, response_body = request(endpoint, form_data) + # Use keyword arguments for credentials to maintain backward compatibility + # with custom Consumer subclasses that override request(endpoint, form_data) + response_code, response_body = + if @credentials && type == :import + request(endpoint, form_data, credentials: @credentials, type: type) + else + request(endpoint, form_data) + end rescue => e raise ConnectionError.new("Could not connect to Mixpanel, with error \"#{e.message}\".") end @@ -123,11 +146,32 @@ def send(type, message) # # as the result of the response. Response code should be nil if # the request never receives a response for some reason. - def request(endpoint, form_data) + # + # For service account authentication, pass credentials (ServiceAccountCredentials object) + # and type (:import) as keyword arguments. + # The positional parameters are preserved for backward compatibility with custom Consumer subclasses. + def request(endpoint, form_data, credentials: nil, type: nil) uri = URI(endpoint) + + # Add project_id as query parameter for import endpoint with service account credentials + if credentials && type == :import + unless credentials.is_a?(ServiceAccountCredentials) + raise ArgumentError, "credentials must be ServiceAccountCredentials, got #{credentials.class}" + end + + query_params = URI.decode_www_form(uri.query || '').to_h + query_params['project_id'] = credentials.project_id + uri.query = URI.encode_www_form(query_params) + end + request = Net::HTTP::Post.new(uri.request_uri) request.set_form_data(form_data) + # Use Basic Auth with service account credentials for import endpoint + if credentials && type == :import + request.basic_auth(credentials.username, credentials.secret) + end + client = Net::HTTP.new(uri.host, uri.port) client.use_ssl = true client.open_timeout = 10 @@ -182,17 +226,21 @@ class BufferedConsumer # to the Mixpanel::Tracker constructor. If a block is passed to # the constructor, the *_endpoint constructor arguments are # ignored. - def initialize(events_endpoint=nil, update_endpoint=nil, import_endpoint=nil, max_buffer_length=MAX_LENGTH, &block) + # + # @param credentials [ServiceAccountCredentials, nil] Service account credentials for authentication. + # Credentials are only used for the /import endpoint and feature flags. + def initialize(events_endpoint=nil, update_endpoint=nil, import_endpoint=nil, max_buffer_length=MAX_LENGTH, credentials: nil, &block) @max_length = [max_buffer_length, MAX_LENGTH].min @buffers = { :event => [], :profile_update => [], } + @credentials = credentials if block @sink = block else - consumer = Consumer.new(events_endpoint, update_endpoint, import_endpoint) + consumer = Consumer.new(events_endpoint, update_endpoint, nil, import_endpoint, credentials: credentials) @sink = consumer.method(:send!) end end diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb new file mode 100644 index 00000000..217a2cd7 --- /dev/null +++ b/lib/mixpanel-ruby/credentials.rb @@ -0,0 +1,26 @@ +module Mixpanel + # Service account credentials for server-to-server authentication + # This is the recommended authentication method over API keys + class ServiceAccountCredentials + attr_reader :username, :secret, :project_id + + # Create service account credentials + # @param username [String] Service account username + # @param secret [String] Service account secret + # @param project_id [String, Integer] Mixpanel project ID (accepts string or integer) + def initialize(username, secret, project_id) + raise ArgumentError, 'username is required' if username.nil? || username.empty? + raise ArgumentError, 'secret is required' if secret.nil? || secret.empty? + raise ArgumentError, 'project_id is required' if project_id.nil? + + # Convert project_id to string if it's an integer (Mixpanel dashboard shows numeric IDs) + project_id = project_id.to_s if project_id.is_a?(Integer) + raise ArgumentError, 'project_id is required' if project_id.empty? + + @username = username + @secret = secret + @project_id = project_id + end + + end +end diff --git a/lib/mixpanel-ruby/events.rb b/lib/mixpanel-ruby/events.rb index be653cda..eb9acb04 100644 --- a/lib/mixpanel-ruby/events.rb +++ b/lib/mixpanel-ruby/events.rb @@ -22,14 +22,19 @@ class Events # # tracker has all of the methods of Mixpanel::Events # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) # - def initialize(token, error_handler=nil, &block) + def initialize(token, error_handler=nil, credentials: nil, &block) @token = token @error_handler = error_handler || ErrorHandler.new + @credentials = credentials + + if block && credentials + warn '[WARNING] credentials passed to Events/Tracker are ignored when a custom sink block is provided. Pass credentials to your consumer directly.' + end if block @sink = block else - consumer = Consumer.new + consumer = Consumer.new(credentials: credentials) @sink = consumer.method(:send!) end end @@ -87,19 +92,66 @@ def track(distinct_id, event, properties={}, ip=nil) # we pass the time of the method call as the time the event occured, if you # wish to override this pass a timestamp in the properties hash. # - # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) - # - # # Track that user "12345"'s credit card was declined - # tracker.import("API_KEY", "12345", "Credit Card Declined") - # - # # Properties describe the circumstances of the event, - # # or aspects of the source or user associated with the event - # tracker.import("API_KEY", "12345", "Welcome Email Sent", { + # # Recommended: Use import_events() instead + # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + # tracker.import_events("12345", "Welcome Email Sent", { # 'Email Template' => 'Pretty Pink Welcome', # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1369353600, # }) + # + # # Legacy: Pass API key as first parameter (deprecated) + # tracker.import("API_KEY", "12345", "Credit Card Declined") + # def import(api_key, distinct_id, event, properties={}, ip=nil) + # Warn about deprecated API key usage + if api_key && !api_key.to_s.empty? + warn '[DEPRECATION] Passing api_key to import() is deprecated. Use ServiceAccountCredentials in the constructor instead. See https://docs.mixpanel.com/docs/tracking-methods/sdks/ruby#service-account-authentication' + end + + # Warn when using import(nil, ...) - recommend import_events instead + if api_key.nil? && @credentials + warn '[DEPRECATION] Using import(nil, ...) is deprecated. Use import_events(...) instead for cleaner API.' + end + + # Delegate to internal implementation + import_internal(api_key, distinct_id, event, properties, ip) + end + + # Import an event using service account credentials from the constructor. + # This is the recommended method for importing historical events with service accounts. + # + # Credentials must be provided in the Tracker/Events constructor. This method is cleaner + # than import() as it doesn't require passing nil as the first parameter. + # + # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + # + # tracker.import_events('user123', 'Past Event', { + # 'time' => 1369353600, + # 'Source' => 'Import' + # }) + # + def import_events(distinct_id, event, properties={}, ip=nil) + unless @credentials + raise ArgumentError, 'import_events requires credentials in constructor. Use: Tracker.new(token, credentials: credentials)' + end + + # Delegate to internal implementation with nil api_key (uses constructor credentials) + import_internal(nil, distinct_id, event, properties, ip) + end + + private + + # Internal implementation for importing events. + # Called by both import() and import_events(). + def import_internal(api_key, distinct_id, event, properties, ip) + # Validate that at least one authentication method is provided + if api_key.nil? && @credentials.nil? + raise ArgumentError, 'import requires authentication: provide either api_key parameter or credentials in constructor' + end + properties = { 'distinct_id' => distinct_id, 'token' => @token, @@ -116,9 +168,15 @@ def import(api_key, distinct_id, event, properties={}, ip=nil) message = { 'data' => data, - 'api_key' => api_key, } + # Only include api_key in message if provided (legacy auth) + # When using service account credentials (recommended), pass nil for api_key + # and the Consumer will use credentials from its instance variable + if api_key + message['api_key'] = api_key + end + ret = true begin @sink.call(:import, message.to_json) diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 116f0115..a0da1a8a 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -12,7 +12,7 @@ module Flags # Base class for feature flags providers # Provides common HTTP handling and exposure event tracking class FlagsProvider - # @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds + # @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds, :credentials (optional) # @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions') # @param tracker_callback [Proc] Function used to track events (bound tracker.track method) # @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote' @@ -23,6 +23,7 @@ def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, err @tracker_callback = tracker_callback @evaluation_mode = evaluation_mode @error_handler = error_handler + @credentials = provider_config[:credentials] end # Make HTTP request to flags API endpoint @@ -31,12 +32,20 @@ def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, err # @raise [Mixpanel::ConnectionError] on network errors # @raise [Mixpanel::ServerError] on HTTP errors def call_flags_endpoint(additional_params = nil) + # Always use token in query params common_params = Utils.prepare_common_query_params( @provider_config[:token], Mixpanel::VERSION ) params = common_params.merge(additional_params || {}) + + # Add project_id as query parameter when using service account credentials + # Note: project_id is required for service account auth but not used for token auth + if @credentials + params['project_id'] = @credentials.project_id + end + query_string = URI.encode_www_form(params) uri = URI::HTTPS.build( @@ -53,7 +62,12 @@ def call_flags_endpoint(additional_params = nil) request = Net::HTTP::Get.new(uri.request_uri) - request.basic_auth(@provider_config[:token], '') + # Use service account credentials for basic auth if provided, otherwise use token + if @credentials + request.basic_auth(@credentials.username, @credentials.secret) + else + request.basic_auth(@provider_config[:token], '') + end request['Content-Type'] = 'application/json' request['traceparent'] = Utils.generate_traceparent diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index a434053e..5ea98faa 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -18,7 +18,8 @@ class LocalFlagsProvider < FlagsProvider # @param config [Hash] Local flags configuration # @param tracker_callback [Proc] Callback to track events # @param error_handler [Mixpanel::ErrorHandler] Error handler - def initialize(token, config, tracker_callback, error_handler) + # @param credentials [ServiceAccountCredentials, nil] Optional service account credentials + def initialize(token, config, tracker_callback, error_handler, credentials = nil) # compact: an explicit nil from the caller (e.g. # polling_interval_in_seconds: nil) must not override a sane default. # Both the previous sleep(nil) and the current @@ -35,7 +36,8 @@ def initialize(token, config, tracker_callback, error_handler) provider_config = { token: token, api_host: @config[:api_host], - request_timeout_in_seconds: @config[:request_timeout_in_seconds] + request_timeout_in_seconds: @config[:request_timeout_in_seconds], + credentials: credentials } super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler) diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb index eaa64713..661208e3 100644 --- a/lib/mixpanel-ruby/flags/remote_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -14,13 +14,15 @@ class RemoteFlagsProvider < FlagsProvider # @param config [Hash] Remote flags configuration # @param tracker_callback [Proc] Callback to track events # @param error_handler [Mixpanel::ErrorHandler] Error handler - def initialize(token, config, tracker_callback, error_handler) + # @param credentials [ServiceAccountCredentials, nil] Optional service account credentials + def initialize(token, config, tracker_callback, error_handler, credentials = nil) merged_config = DEFAULT_CONFIG.merge(config || {}) provider_config = { token: token, api_host: merged_config[:api_host], - request_timeout_in_seconds: merged_config[:request_timeout_in_seconds] + request_timeout_in_seconds: merged_config[:request_timeout_in_seconds], + credentials: credentials } super(provider_config, '/flags', tracker_callback, 'remote', error_handler) diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 667c672e..ff30f939 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -62,9 +62,14 @@ class Tracker < Events # If a block is provided, it is passed a type (one of :event or :profile_update) # and a string message. This same format is accepted by Mixpanel::Consumer#send! # and Mixpanel::BufferedConsumer#send! - def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_config: nil, &block) - super(token, error_handler, &block) - @token = token + # + # Optional parameters: + # - credentials: ServiceAccountCredentials for authentication (used for import and feature flags) + # - local_flags_config: Configuration hash for local feature flags + # - remote_flags_config: Configuration hash for remote feature flags + def initialize(token, error_handler=nil, credentials: nil, local_flags_config: nil, remote_flags_config: nil, &block) + super(token, error_handler, credentials: credentials, &block) + @people = People.new(token, error_handler, &block) @groups = Groups.new(token, error_handler, &block) @@ -74,7 +79,8 @@ def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_c token, local_flags_config, method(:track), # Pass bound method as callback - error_handler || ErrorHandler.new + error_handler || ErrorHandler.new, + credentials ) end @@ -84,7 +90,8 @@ def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_c token, remote_flags_config, method(:track), # Pass bound method as callback - error_handler || ErrorHandler.new + error_handler || ErrorHandler.new, + credentials ) end end @@ -122,14 +129,15 @@ def track(distinct_id, event, properties={}, ip=nil) # # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) # - # # Import event that user "12345"'s credit card was declined + # # Using deprecated API key (still supported) # tracker.import("API_KEY", "12345", "Credit Card Declined", { # 'time' => 1310111365 # }) # - # # Properties describe the circumstances of the event, - # # or aspects of the source or user associated with the event - # tracker.import("API_KEY", "12345", "Welcome Email Sent", { + # # Using service account credentials (recommended) + # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + # tracker.import(nil, "12345", "Welcome Email Sent", { # 'Email Template' => 'Pretty Pink Welcome', # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1310111365 @@ -140,6 +148,28 @@ def import(api_key, distinct_id, event, properties={}, ip=nil) super end + # Import an event using service account credentials from the constructor. + # This is the recommended method for importing historical events with service accounts. + # + # Service account credentials must be provided when creating the Tracker. + # This method provides a cleaner API than import() as it doesn't require + # passing nil as the first parameter. + # + # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + # + # # Import a historical event + # tracker.import_events('user123', 'Past Event', { + # 'Email Template' => 'Welcome Email', + # 'time' => 1369353600 + # }) + # + def import_events(distinct_id, event, properties={}, ip=nil) + # This is here strictly to allow rdoc to include the relevant + # documentation + super + end + # Creates a distinct_id alias. \Events and updates with an alias # will be considered by mixpanel to have the same source, and # refer to the same profile. diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 941f2561..a1bbf474 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -3,6 +3,7 @@ require 'webmock' require 'mixpanel-ruby/consumer' +require 'mixpanel-ruby/credentials' describe Mixpanel::Consumer do before { WebMock.reset! } @@ -95,6 +96,56 @@ def request(*args) it_behaves_like 'consumer' end + context 'service account credentials' do + it 'should send a request to api.mixpanel.com/import with service account credentials' do + stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project-123').to_return({:body => '{"status": 1, "error": null}'}) + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123') + consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials) + + consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE'}.to_json) + + # Should use Basic Auth header with username:secret + # Should add project_id as query parameter + # Should NOT include credentials in POST body or message + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project-123'). + with( + :body => { + 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', + 'verbose' => '1' + }, + :headers => { + 'Authorization' => 'Basic ' + Base64.strict_encode64('test-user:test-secret') + } + ) + end + + it 'should raise ArgumentError when credentials is not ServiceAccountCredentials' do + consumer = Mixpanel::Consumer.new + + expect { + consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: 'invalid', type: :import) + }.to raise_error(ArgumentError, /credentials must be ServiceAccountCredentials, got String/) + end + + it 'should not include api_key when credentials are present' do + stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project').to_return({:body => '{"status": 1, "error": null}'}) + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'test-project') + consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials) + + # Message includes api_key but it should be ignored when credentials are present + consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'api_key' => 'SHOULD_BE_IGNORED'}.to_json) + + # api_key should NOT be in the request body (only data and verbose) + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project'). + with( + :body => { + 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', + 'verbose' => '1' + } + ) + end + end + end describe Mixpanel::BufferedConsumer do @@ -205,4 +256,34 @@ def request(*args) end end + context 'with service account credentials' do + it 'should pass credentials to consumer when using BufferedConsumer' do + stub_request(:any, 'https://api.mixpanel.com/import?project_id=buffered-project').to_return({:body => '{"status": 1, "error": null}'}) + credentials = Mixpanel::ServiceAccountCredentials.new('buffered-user', 'buffered-secret', 'buffered-project') + consumer = Mixpanel::BufferedConsumer.new(nil, nil, nil, 2, credentials: credentials) + + # Import messages are not buffered - they're sent immediately + consumer.send!(:import, {'data' => 'EVENT 1'}.to_json) + + # Verify credentials were used in the request + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=buffered-project'). + with( + :headers => { + 'Authorization' => 'Basic ' + Base64.strict_encode64('buffered-user:buffered-secret') + } + ).once + end + end + +end + +describe 'Connection error handling' do + it 'should raise ConnectionError when network error occurs' do + stub_request(:any, 'https://api.mixpanel.com/track').to_raise(StandardError.new('Network timeout')) + consumer = Mixpanel::Consumer.new + + expect { + consumer.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json) + }.to raise_error(Mixpanel::ConnectionError, /Could not connect to Mixpanel, with error "Network timeout"/) + end end diff --git a/spec/mixpanel-ruby/credentials_security_spec.rb b/spec/mixpanel-ruby/credentials_security_spec.rb new file mode 100644 index 00000000..fdfd4778 --- /dev/null +++ b/spec/mixpanel-ruby/credentials_security_spec.rb @@ -0,0 +1,108 @@ +require 'mixpanel-ruby' +require 'json' + +describe 'Credentials Security' do + describe 'import with constructor credentials (recommended)' do + it 'should NOT include credentials in message payload' do + messages = [] + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + # Create tracker with credentials in constructor + tracker = Mixpanel::Tracker.new('TOKEN', nil, credentials: credentials) do |type, message| + messages << [type, message] + end + + # Call import with nil api_key (recommended pattern) + tracker.import(nil, 'user123', 'Test Event', {'prop' => 'value'}) + + # Verify message was sent + expect(messages.length).to eq(1) + type, message_json = messages[0] + expect(type).to eq(:import) + + # Parse the message + message = JSON.parse(message_json) + + # CRITICAL: Credentials should NOT be in the message + expect(message).not_to have_key('credentials') + expect(message_json).not_to include('test-secret') + expect(message_json).not_to include('test-user') + + # Message should only contain data + expect(message).to have_key('data') + expect(message['data']).to have_key('event') + expect(message['data']['event']).to eq('Test Event') + end + + it 'should NOT include credentials in buffered messages' do + messages = [] + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + # Create buffered consumer with credentials + consumer = Mixpanel::BufferedConsumer.new(nil, nil, nil, 5, credentials: credentials) do |type, message| + messages << [type, message] + end + + # Send multiple event messages (events are buffered, imports are not) + 5.times do |i| + consumer.send!(:event, {'data' => {'event' => "Event #{i}"}}.to_json) + end + + # Verify batched message was sent (auto-flushed when buffer reaches max_length) + expect(messages.length).to eq(1) + type, message_json = messages[0] + + # CRITICAL: Credentials should NOT be in the batched message + expect(message_json).not_to include('credentials') + expect(message_json).not_to include('test-secret') + expect(message_json).not_to include('test-user') + end + end + + describe 'tracking events' do + it 'should never include credentials in event messages' do + messages = [] + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + tracker = Mixpanel::Tracker.new('TOKEN', nil, credentials: credentials) do |type, message| + messages << [type, message] + end + + # Track a regular event + tracker.track('user123', 'Test Event', {'prop' => 'value'}) + + # Verify message + expect(messages.length).to eq(1) + type, message_json = messages[0] + expect(type).to eq(:event) + + # Credentials should NEVER be in event messages + expect(message_json).not_to include('credentials') + expect(message_json).not_to include('test-secret') + end + end + + describe 'legacy API key pattern' do + it 'should include api_key in message but not credentials' do + messages = [] + tracker = Mixpanel::Tracker.new('TOKEN') do |type, message| + messages << [type, message] + end + + # Use legacy API key pattern + tracker.import('MY_API_KEY', 'user123', 'Test Event', {'prop' => 'value'}) + + # Verify message + expect(messages.length).to eq(1) + type, message_json = messages[0] + message = JSON.parse(message_json) + + # API key should be in message (legacy pattern) + expect(message).to have_key('api_key') + expect(message['api_key']).to eq('MY_API_KEY') + + # But credentials should NOT be + expect(message).not_to have_key('credentials') + end + end +end diff --git a/spec/mixpanel-ruby/credentials_spec.rb b/spec/mixpanel-ruby/credentials_spec.rb new file mode 100644 index 00000000..61cdbe84 --- /dev/null +++ b/spec/mixpanel-ruby/credentials_spec.rb @@ -0,0 +1,54 @@ +require 'mixpanel-ruby' + +describe Mixpanel::ServiceAccountCredentials do + describe '#initialize' do + it 'creates credentials with valid parameters' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + expect(credentials.username).to eq('user') + expect(credentials.secret).to eq('secret') + expect(credentials.project_id).to eq('project123') + end + + it 'raises ArgumentError when username is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new(nil, 'secret', 'project123') + }.to raise_error(ArgumentError, 'username is required') + end + + it 'raises ArgumentError when username is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('', 'secret', 'project123') + }.to raise_error(ArgumentError, 'username is required') + end + + it 'raises ArgumentError when secret is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', nil, 'project123') + }.to raise_error(ArgumentError, 'secret is required') + end + + it 'raises ArgumentError when secret is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', '', 'project123') + }.to raise_error(ArgumentError, 'secret is required') + end + + it 'raises ArgumentError when project_id is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', 'secret', nil) + }.to raise_error(ArgumentError, 'project_id is required') + end + + it 'raises ArgumentError when project_id is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', 'secret', '') + }.to raise_error(ArgumentError, 'project_id is required') + end + + it 'accepts integer project_id and converts to string' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 12345) + expect(credentials.project_id).to eq('12345') + end + end + +end diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index 57f6adec..73a2ee07 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -3,6 +3,7 @@ require 'mixpanel-ruby/events.rb' require 'mixpanel-ruby/version.rb' +require 'mixpanel-ruby/credentials.rb' describe Mixpanel::Events do before(:each) do @@ -73,4 +74,155 @@ } } ]]) end + + it 'should send a well formed import/ message with service account credentials' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123') + + # Create new Events instance with credentials in constructor + events_with_creds = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + @log << [type, JSON.load(message)] + end + + # New API: pass nil for api_key, credentials come from instance + events_with_creds.import(nil, 'TEST ID', 'Test Event', { + 'Circumstances' => 'During a test' + }) + + expect(@log.length).to eq(1) + expect(@log[0][0]).to eq(:import) + + message = @log[0][1] + # Credentials should NOT be in message (secure API) + expect(message).not_to have_key('credentials') + expect(message).not_to have_key('api_key') + expect(message['data']).to eq({ + 'event' => 'Test Event', + 'properties' => { + 'Circumstances' => 'During a test', + 'distinct_id' => 'TEST ID', + 'mp_lib' => 'ruby', + '$lib_version' => Mixpanel::VERSION, + 'token' => 'TEST TOKEN', + 'time' => @time_now.to_i * 1000 + } + }) + end + + it 'should raise ArgumentError when import is called without authentication' do + # Create Events without credentials + events = Mixpanel::Events.new('TEST TOKEN') do |type, message| + @log << [type, JSON.load(message)] + end + + # Try to import with nil api_key and no credentials + expect { + events.import(nil, 'TEST ID', 'Test Event', {}) + }.to raise_error(ArgumentError, /import requires authentication/) + end + + describe 'import_events' do + it 'should send a well formed import/ message with constructor credentials' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123') + + events_with_creds = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + @log << [type, JSON.load(message)] + end + + # Clean API - no nil! + events_with_creds.import_events('TEST ID', 'Test Event', { + 'Circumstances' => 'During a test' + }) + + expect(@log.length).to eq(1) + expect(@log[0][0]).to eq(:import) + + message = @log[0][1] + # Credentials should NOT be in message + expect(message).not_to have_key('credentials') + expect(message).not_to have_key('api_key') + expect(message['data']).to eq({ + 'event' => 'Test Event', + 'properties' => { + 'Circumstances' => 'During a test', + 'distinct_id' => 'TEST ID', + 'mp_lib' => 'ruby', + '$lib_version' => Mixpanel::VERSION, + 'token' => 'TEST TOKEN', + 'time' => @time_now.to_i * 1000 + } + }) + end + + it 'should raise ArgumentError when called without constructor credentials' do + events = Mixpanel::Events.new('TEST TOKEN') do |type, message| + @log << [type, JSON.load(message)] + end + + expect { + events.import_events('TEST ID', 'Test Event', {}) + }.to raise_error(ArgumentError, /import_events requires credentials in constructor/) + end + end + + describe 'warnings' do + it 'should warn when credentials are passed with a custom block' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project') + + expect { + Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + # Custom block + end + }.to output(/credentials passed to Events\/Tracker are ignored/).to_stderr + end + + it 'should not warn when credentials are passed without a block' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project') + + expect { + Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) + }.not_to output.to_stderr + end + + it 'should warn when using deprecated api_key in import' do + events = Mixpanel::Events.new('TEST TOKEN') do |type, message| + @log << [type, JSON.load(message)] + end + + expect { + events.import('API_KEY', 'TEST ID', 'Test Event', {}) + }.to output(/DEPRECATION.*api_key to import\(\) is deprecated/).to_stderr + + # Second call should also warn + expect { + events.import('API_KEY', 'TEST ID', 'Test Event 2', {}) + }.to output(/DEPRECATION.*api_key to import\(\) is deprecated/).to_stderr + end + + it 'should warn when using import(nil, ...) with credentials' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project') + events = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + @log << [type, JSON.load(message)] + end + + expect { + events.import(nil, 'TEST ID', 'Test Event', {}) + }.to output(/DEPRECATION.*import\(nil, \.\.\.\) is deprecated.*import_events/).to_stderr + + # Second call should also warn + expect { + events.import(nil, 'TEST ID', 'Test Event 2', {}) + }.to output(/DEPRECATION.*import\(nil, \.\.\.\) is deprecated.*import_events/).to_stderr + end + + it 'should not warn when using import_events' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project') + events = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + @log << [type, JSON.load(message)] + end + + expect { + events.import_events('TEST ID', 'Test Event', {}) + }.not_to output(/DEPRECATION/).to_stderr + end + end end diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 1302f88b..fad90d02 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -2,6 +2,7 @@ require 'timeout' require 'mixpanel-ruby/flags/local_flags_provider' require 'mixpanel-ruby/flags/types' +require 'mixpanel-ruby/credentials' require 'webmock/rspec' describe Mixpanel::Flags::LocalFlagsProvider do @@ -17,7 +18,8 @@ test_token, config, mock_tracker, - mock_error_handler + mock_error_handler, + nil # credentials ) end @@ -770,7 +772,8 @@ def user_context_with_properties(properties) test_token, { enable_polling: true, polling_interval_in_seconds: 0.1 }, mock_tracker, - mock_error_handler + mock_error_handler, + nil # credentials ) begin @@ -929,4 +932,35 @@ def user_context_with_properties(properties) end end end + + describe 'service account credentials' do + it 'uses service account credentials for authentication' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + flag = create_test_flag + + stub_request(:get, endpoint_url_regex) + .with( + basic_auth: ['test-user', 'test-secret'] + ) + .to_return( + status: 200, + body: { code: 200, flags: [flag] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + credentials_provider = Mixpanel::Flags::LocalFlagsProvider.new( + test_token, + config, + mock_tracker, + mock_error_handler, + credentials + ) + + credentials_provider.start_polling_for_definitions! + result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false) + + expect(result).not_to eq('fallback') + credentials_provider.stop_polling_for_definitions! + end + end end diff --git a/spec/mixpanel-ruby/flags/remote_flags_spec.rb b/spec/mixpanel-ruby/flags/remote_flags_spec.rb index 8e7308b7..6fcee63a 100644 --- a/spec/mixpanel-ruby/flags/remote_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/remote_flags_spec.rb @@ -1,6 +1,7 @@ require 'json' require 'mixpanel-ruby/flags/remote_flags_provider' require 'mixpanel-ruby/flags/types' +require 'mixpanel-ruby/credentials' require 'webmock/rspec' describe Mixpanel::Flags::RemoteFlagsProvider do @@ -17,6 +18,7 @@ config, mock_tracker, mock_error_handler + # credentials defaults to nil ) end @@ -438,4 +440,38 @@ def stub_flags_request_error(error) provider.send(:track_exposure_event, 'test_flag', variant, test_context) end end + + describe 'service account credentials' do + it 'uses service account credentials for authentication' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 'treatment' + } + }) + + stub_request(:get, endpoint_url_regex) + .with( + basic_auth: ['test-user', 'test-secret'] + ) + .to_return( + status: 200, + body: response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + credentials_provider = Mixpanel::Flags::RemoteFlagsProvider.new( + test_token, + config, + mock_tracker, + mock_error_handler, + credentials + ) + + result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false) + expect(result).to eq('treatment') + end + end end diff --git a/spec/mixpanel-ruby/tracker_spec.rb b/spec/mixpanel-ruby/tracker_spec.rb index 1fe54eb3..1aa3a8a6 100644 --- a/spec/mixpanel-ruby/tracker_spec.rb +++ b/spec/mixpanel-ruby/tracker_spec.rb @@ -131,4 +131,22 @@ expect(expect).to eq(found) end end + + describe 'service account credentials' do + it 'should pass credentials to flags providers when passed directly' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + + tracker = Mixpanel::Tracker.new( + 'TEST TOKEN', + nil, + credentials: credentials, + local_flags_config: {}, + remote_flags_config: {} + ) + + # Verify credentials were passed to providers by checking internal state + expect(tracker.local_flags.instance_variable_get(:@credentials)).to eq(credentials) + expect(tracker.remote_flags.instance_variable_get(:@credentials)).to eq(credentials) + end + end end