From 4c5b349766f9f745a464c927fccae0881c417940 Mon Sep 17 00:00:00 2001 From: Ryan Rader Date: Sat, 17 Oct 2026 10:31:25 -0400 Subject: [PATCH 1/4] fix: harden portal plans and outbound requests ## Context Several code paths accepted input more broadly than the UI or configuration intended. ## Description - Share the customer portal plan visibility rules between the available plans resolver and the portal subscription mutations, and escape LIKE wildcards in product key lookups. - Resolve and check outbound destinations for webhook endpoints and Okta hosts, pin connections to the checked address, and validate on save and on send. LAGO_OUTBOUND_ALLOW_PRIVATE disables the checks outside production. - Apply the signup setting and a per-user limit to organization creation. - Add a white_label API key permission resource. - Bind white-label gate links to a nonce rotated on acceptance and reset, and stop falling back to the customer portal on repeat visits. - Restrict portal return_to to LAGO_PORTAL_RETURN_TO_HOSTS. - Limit the image build workflow to the main branch. --- .github/workflows/build-and-push.yml | 2 + .../api/v1/white_label_controller.rb | 8 +- app/controllers/white_label_controller.rb | 31 +++- .../subscriptions/change_plan.rb | 15 +- .../customer_portal/subscriptions/create.rb | 10 +- .../subscriptions/terminate.rb | 6 +- app/graphql/mutations/organizations/create.rb | 55 ++++++- .../available_plans_resolver.rb | 42 +---- app/models/api_key.rb | 2 +- app/models/integrations/okta_integration.rb | 9 ++ app/models/webhook_endpoint.rb | 9 ++ app/models/white_label_agreement.rb | 64 +++++++- app/services/auth/okta/base_service.rb | 10 +- .../customer_portal/available_plans_query.rb | 92 +++++++++++ .../customer_portal/generate_url_service.rb | 27 +++- app/services/webhooks/send_http_service.rb | 3 +- ...add_white_label_to_full_access_api_keys.rb | 34 ++++ ...d_token_nonce_to_white_label_agreements.rb | 9 ++ lib/lago_http_client/lago_http_client.rb | 1 + .../lago_http_client/client.rb | 18 ++- .../lago_http_client/outbound_url_guard.rb | 149 ++++++++++++++++++ spec/factories/white_label_agreements.rb | 12 ++ .../subscriptions/change_plan_spec.rb | 72 +++++++++ .../subscriptions/create_spec.rb | 66 ++++++++ .../subscriptions/terminate_spec.rb | 50 ++++++ .../mutations/organizations/create_spec.rb | 75 +++++++++ spec/lib/lago_http_client/client_spec.rb | 44 ++++++ .../outbound_url_guard_spec.rb | 96 +++++++++++ .../integrations/okta_integration_spec.rb | 21 +++ spec/models/webhook_endpoint_spec.rb | 23 +++ spec/models/white_label_agreement_spec.rb | 84 ++++++++++ .../api/v1/white_label_controller_spec.rb | 36 +++++ spec/requests/white_label_controller_spec.rb | 58 +++++++ spec/services/auth/okta/login_service_spec.rb | 9 ++ .../available_plans_query_spec.rb | 68 ++++++++ .../generate_url_service_spec.rb | 52 ++++++ .../webhooks/send_http_service_spec.rb | 21 ++- spec/support/outbound_url_guard_helper.rb | 10 ++ 38 files changed, 1314 insertions(+), 79 deletions(-) create mode 100644 app/services/customer_portal/available_plans_query.rb create mode 100644 db/migrate/20261017000000_add_white_label_to_full_access_api_keys.rb create mode 100644 db/migrate/20261017000001_add_token_nonce_to_white_label_agreements.rb create mode 100644 lib/lago_http_client/lago_http_client/outbound_url_guard.rb create mode 100644 spec/factories/white_label_agreements.rb create mode 100644 spec/graphql/mutations/customer_portal/subscriptions/change_plan_spec.rb create mode 100644 spec/graphql/mutations/customer_portal/subscriptions/create_spec.rb create mode 100644 spec/graphql/mutations/customer_portal/subscriptions/terminate_spec.rb create mode 100644 spec/graphql/mutations/organizations/create_spec.rb create mode 100644 spec/lib/lago_http_client/outbound_url_guard_spec.rb create mode 100644 spec/models/white_label_agreement_spec.rb create mode 100644 spec/requests/api/v1/white_label_controller_spec.rb create mode 100644 spec/requests/white_label_controller_spec.rb create mode 100644 spec/services/customer_portal/available_plans_query_spec.rb create mode 100644 spec/support/outbound_url_guard_helper.rb diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index aec598fa1ab5..4d3f0a4b2241 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -17,6 +17,8 @@ env: jobs: build-and-push: + # Only ever publish images built from main (also applies to manual runs). + if: github.ref == 'refs/heads/main' && github.repository == 'ThinkfleetAI/lago-api' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/app/controllers/api/v1/white_label_controller.rb b/app/controllers/api/v1/white_label_controller.rb index 6176c876e8bc..f40a1095cdcf 100644 --- a/app/controllers/api/v1/white_label_controller.rb +++ b/app/controllers/api/v1/white_label_controller.rb @@ -85,10 +85,10 @@ def not_found_result(resource) BaseService::Result.new.not_found_failure!(resource:) end - # Authentication (a valid org API key) is sufficient for this internal - # endpoint; there is no per-resource API-key permission for white_label. - def authorize - true + # `provision` and `reset` are POSTs and require `white_label:write`; + # `index` requires `white_label:read` (see Api::BaseController#mode). + def resource_name + "white_label" end end end diff --git a/app/controllers/white_label_controller.rb b/app/controllers/white_label_controller.rb index bbdaf7803b4e..1295bf464bcc 100644 --- a/app/controllers/white_label_controller.rb +++ b/app/controllers/white_label_controller.rb @@ -28,8 +28,11 @@ def show case agreement.status when "pending" then render_gate(agreement) - when "accepted" then proceed_to_payment(agreement) # signed but no card yet → go pay - when "active" then render_status(agreement) # all set + # Signed but no card yet → resume the Stripe checkout. Only reachable with + # a post-acceptance link (the signing link is invalidated on acceptance), + # and never falls back to minting a customer portal session. + when "accepted" then resume_payment(agreement) + when "active" then render_status(agreement) else render_invalid end end @@ -37,8 +40,8 @@ def show def accept agreement = WhiteLabelAgreement.from_token(params[:token]) return render_invalid if agreement.nil? - # Already signed (e.g. resubmitted) → straight to payment, don't re-sign. - return proceed_to_payment(agreement) if agreement.status == "accepted" + # Already signed (e.g. resubmitted) → resume payment, don't re-sign. + return resume_payment(agreement) if agreement.status == "accepted" return render_status(agreement) unless agreement.status == "pending" unless params[:accept] == "1" && params[:signer_name].present? && params[:signer_title].present? @@ -96,8 +99,11 @@ def render_gate(agreement, error: nil) end def render_status(agreement) - msg = if agreement.status == "active" + msg = case agreement.status + when "active" "This agreement is active and billing is set up. Nothing further is needed." + when "accepted" + "This agreement has been signed. To finish adding a payment method, please contact your Flobyte representative." else "This agreement is no longer available." end @@ -116,6 +122,17 @@ def proceed_to_payment(agreement) redirect_to_portal(agreement.customer) end + # Repeat visit after acceptance: only ever send the signer back to Stripe + # checkout. No customer-portal fallback here. + def resume_payment(agreement) + ensure_provider_customer(agreement.customer) + + url = white_label_checkout_url(agreement) + return redirect_to(url, allow_other_host: true) if url.present? + + render_status(agreement) + end + # Build a Stripe setup-mode Checkout Session with OUR own no-login success page # (the org's provider-level success_redirect_url points at an in-app, login- # required page — wrong for a no-login signer). The existing @@ -132,7 +149,7 @@ def white_label_checkout_url(agreement) customer: pc.provider_customer_id, payment_method_types: ["card"], success_url: "#{base}/white-label/done", - cancel_url: agreement.signing_url + cancel_url: agreement.payment_resume_url } # Setup mode shows no amount; surface the recurring price near the submit # button so it doesn't look like a blank purchase. @@ -158,7 +175,7 @@ def plan_price_text(agreement) return if plan.nil? amount = plan.amount_cents.to_i / 100.0 - unit = (plan.amount_currency.presence || "USD") == "USD" ? "$" : "#{plan.amount_currency} " + unit = ((plan.amount_currency.presence || "USD") == "USD") ? "$" : "#{plan.amount_currency} " price = ActiveSupport::NumberHelper.number_to_currency(amount, unit:, precision: 2) interval = (plan.interval.to_s == "yearly") ? "year" : "month" "#{price}/#{interval}" diff --git a/app/graphql/mutations/customer_portal/subscriptions/change_plan.rb b/app/graphql/mutations/customer_portal/subscriptions/change_plan.rb index 94be85611757..54d219d321ff 100644 --- a/app/graphql/mutations/customer_portal/subscriptions/change_plan.rb +++ b/app/graphql/mutations/customer_portal/subscriptions/change_plan.rb @@ -30,13 +30,18 @@ class ChangePlan < BaseMutation def resolve(plan_code:) customer = context[:customer_portal_user] - product_key = plan_code.split("-").first + product_key = ::CustomerPortal::AvailablePlansQuery.product_key_for(plan_code) current_subscription = customer.subscriptions.active.find_by( - "external_id LIKE ?", "%-#{product_key}" + "external_id LIKE ?", ::CustomerPortal::AvailablePlansQuery.external_id_suffix_pattern(product_key) ) - return execution_error(message: "no_active_subscription_for_product") if current_subscription.nil? + if current_subscription.nil? + return execution_error(error: "no_active_subscription_for_product", status: 404, code: "no_active_subscription_for_product") + end - plan = customer.organization.plans.find_by!(code: plan_code, parent_id: nil) + plan = ::CustomerPortal::AvailablePlansQuery.find_plan(customer:, code: plan_code) + if plan.nil? + return execution_error(error: "plan_not_found", status: 404, code: "plan_not_found") + end result = ::Subscriptions::PlanUpgradeService.call( current_subscription: current_subscription, @@ -45,8 +50,6 @@ def resolve(plan_code:) ) result.success? ? result.subscription : result_error(result) - rescue ActiveRecord::RecordNotFound - execution_error(message: "plan_not_found") end end end diff --git a/app/graphql/mutations/customer_portal/subscriptions/create.rb b/app/graphql/mutations/customer_portal/subscriptions/create.rb index 948ab375a7c8..533f368dea18 100644 --- a/app/graphql/mutations/customer_portal/subscriptions/create.rb +++ b/app/graphql/mutations/customer_portal/subscriptions/create.rb @@ -20,8 +20,12 @@ class Create < BaseMutation def resolve(plan_code:) customer = context[:customer_portal_user] - plan = customer.organization.plans.find_by!(code: plan_code, parent_id: nil) - product_key = plan_code.split("-").first + plan = ::CustomerPortal::AvailablePlansQuery.find_plan(customer:, code: plan_code) + if plan.nil? + return execution_error(error: "plan_not_found", status: 404, code: "plan_not_found") + end + + product_key = ::CustomerPortal::AvailablePlansQuery.product_key_for(plan_code) external_id = "#{customer.external_id}-#{product_key}" result = ::Subscriptions::CreateService.call( @@ -36,8 +40,6 @@ def resolve(plan_code:) ) result.success? ? result.subscription : result_error(result) - rescue ActiveRecord::RecordNotFound - execution_error(message: "plan_not_found") end end end diff --git a/app/graphql/mutations/customer_portal/subscriptions/terminate.rb b/app/graphql/mutations/customer_portal/subscriptions/terminate.rb index dac9e9fd1d08..73e5761df1fe 100644 --- a/app/graphql/mutations/customer_portal/subscriptions/terminate.rb +++ b/app/graphql/mutations/customer_portal/subscriptions/terminate.rb @@ -21,9 +21,11 @@ class Terminate < BaseMutation def resolve(product_key:) customer = context[:customer_portal_user] subscription = customer.subscriptions.active.find_by( - "external_id LIKE ?", "%-#{product_key}" + "external_id LIKE ?", ::CustomerPortal::AvailablePlansQuery.external_id_suffix_pattern(product_key) ) - return execution_error(message: "no_active_subscription_for_product") if subscription.nil? + if subscription.nil? + return execution_error(error: "no_active_subscription_for_product", status: 404, code: "no_active_subscription_for_product") + end result = ::Subscriptions::TerminateService.call(subscription: subscription) result.success? ? result.subscription : result_error(result) diff --git a/app/graphql/mutations/organizations/create.rb b/app/graphql/mutations/organizations/create.rb index 9168098f5433..fa750984d7d9 100644 --- a/app/graphql/mutations/organizations/create.rb +++ b/app/graphql/mutations/organizations/create.rb @@ -19,25 +19,66 @@ class Create < BaseMutation type Types::Organizations::CurrentOrganizationType + DEFAULT_MAX_ORGANIZATIONS_PER_USER = 10 + def resolve(name:) - result = ::Organizations::CreateService.call( - name:, - document_numbering: "per_organization" - ) - return result_error(result) unless result.success? + user = context[:current_user] + + # Same gate as signup: when signup is disabled, only users who already + # administer an organization may create additional ones. + if signup_disabled? && !admin_memberships(user).exists? + return not_allowed_error(code: "signup_disabled") + end - organization = result.organization + organization = nil + limit_reached = false + result = nil ActiveRecord::Base.transaction do - membership = Membership.create!(user: context[:current_user], organization:) + # Serialize concurrent creations for the same user so the count + # check below cannot be raced. + user.lock! + + if admin_memberships(user).count >= max_organizations_per_user + limit_reached = true + raise ActiveRecord::Rollback + end + + result = ::Organizations::CreateService.call( + name:, + document_numbering: "per_organization" + ) + raise ActiveRecord::Rollback unless result.success? + + organization = result.organization + membership = Membership.create!(user:, organization:) MembershipRole.create!(organization:, membership:, role: Role.admins.first!) end + return forbidden_error(code: "organization_limit_reached") if limit_reached + return result_error(result) unless result.success? + organization rescue ActiveRecord::RecordInvalid => e + result ||= BaseService::Result.new result.record_validation_failure!(record: e.record) result_error(result) end + + private + + def signup_disabled? + ENV.fetch("LAGO_DISABLE_SIGNUP", "false") == "true" + end + + def admin_memberships(user) + user.memberships.active.admins + end + + def max_organizations_per_user + value = ENV.fetch("LAGO_MAX_ORGANIZATIONS_PER_USER", "").to_i + value.positive? ? value : DEFAULT_MAX_ORGANIZATIONS_PER_USER + end end end end diff --git a/app/graphql/resolvers/customer_portal/available_plans_resolver.rb b/app/graphql/resolvers/customer_portal/available_plans_resolver.rb index 8b5c1aa139bf..cf9b3732d073 100644 --- a/app/graphql/resolvers/customer_portal/available_plans_resolver.rb +++ b/app/graphql/resolvers/customer_portal/available_plans_resolver.rb @@ -24,41 +24,13 @@ class AvailablePlansResolver < Resolvers::BaseResolver type [Types::Plans::Object], null: false def resolve(product_key: nil, exclude_current: false) - customer = context[:customer_portal_user] - organization = customer.organization - - # `self_serve: false` plans (contract-only tiers like enterprise / - # white-label) never appear in the portal, but stay assignable by - # operators (this filter only runs here). - plans = organization.plans.where(parent_id: nil) - .where(self_serve: true) - .includes(:metadata) - .order(:amount_cents) - plans = plans.where("code LIKE ?", "#{product_key}-%") if product_key.present? - - if exclude_current - active_plan_codes = customer.subscriptions.active.joins(:plan).pluck("plans.code") - plans = plans.where.not(code: active_plan_codes) if active_plan_codes.any? - end - - # Compute which products this customer already has — used for the - # `visible_to_products` filter below. - customer_product_keys = customer.subscriptions.active - .joins(:plan).pluck("plans.code") - .map { |c| c.split("-").first }.uniq - - plans.to_a.select do |plan| - meta = plan.metadata&.value || {} - # Hard-hide: any plan tagged hidden_in_portal=true never appears. - next false if meta["hidden_in_portal"].to_s == "true" - - # Scoped visibility: if visible_to_products is set, customer must - # already have an active sub in one of those products. If empty/absent, - # plan is visible to all. - allowed = meta["visible_to_products"].to_s.split(",").map(&:strip).reject(&:empty?) - next true if allowed.empty? - (allowed & customer_product_keys).any? - end + # Filtering rules live in CustomerPortal::AvailablePlansQuery so the + # portal subscription mutations enforce exactly the same set. + ::CustomerPortal::AvailablePlansQuery.call( + customer: context[:customer_portal_user], + product_key:, + exclude_current: + ) end end end diff --git a/app/models/api_key.rb b/app/models/api_key.rb index 24b7a8f2278d..4a0dee614902 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -7,7 +7,7 @@ class ApiKey < ApplicationRecord activity_log add_on analytic api_log billable_metric coupon applied_coupon credit_note customer_usage customer event fee invoice organization payment payment_receipt payment_request payment_method plan subscription lifetime_usage tax wallet wallet_transaction webhook_endpoint webhook_jwt_public_key invoice_custom_section - billing_entity alert feature security_log quote + billing_entity alert feature security_log quote white_label ].freeze MODES = %w[read write].freeze diff --git a/app/models/integrations/okta_integration.rb b/app/models/integrations/okta_integration.rb index b66dee5fde51..df1f7044cf09 100644 --- a/app/models/integrations/okta_integration.rb +++ b/app/models/integrations/okta_integration.rb @@ -4,6 +4,7 @@ module Integrations class OktaIntegration < BaseIntegration validates :client_secret, :client_id, :domain, :organization_name, presence: true validate :domain_uniqueness + validate :host_destination settings_accessors :client_id, :domain, :organization_name, :host secrets_accessors :client_secret @@ -14,6 +15,14 @@ def host private + def host_destination + return if organization_name.blank? + + valid = ::LagoHttpClient::OutboundUrlGuard.valid_hostname?(host) && + ::LagoHttpClient::OutboundUrlGuard.allowed?("https://#{host}/") + errors.add(:host, :url_invalid) unless valid + end + def domain_uniqueness return if domain.blank? diff --git a/app/models/webhook_endpoint.rb b/app/models/webhook_endpoint.rb index 2c3d8977143c..0728d7ba0abb 100644 --- a/app/models/webhook_endpoint.rb +++ b/app/models/webhook_endpoint.rb @@ -22,6 +22,7 @@ class WebhookEndpoint < ApplicationRecord validates :webhook_url, presence: true, url: true validates :webhook_url, uniqueness: {scope: :organization_id} + validate :webhook_url_destination, if: :webhook_url_changed? validate :max_webhook_endpoints, on: :create validate :validate_event_types, if: :event_types_changed? @@ -35,6 +36,14 @@ def self.ransackable_attributes(_auth_object = nil) private + def webhook_url_destination + return if webhook_url.blank? || errors.include?(:webhook_url) + + unless ::LagoHttpClient::OutboundUrlGuard.allowed?(webhook_url) + errors.add(:webhook_url, :url_invalid) + end + end + def max_webhook_endpoints errors.add(:base, :exceeded_limit) if organization && organization.webhook_endpoints.reload.count >= LIMIT diff --git a/app/models/white_label_agreement.rb b/app/models/white_label_agreement.rb index 7f271a45b4e2..42f95ff0d351 100644 --- a/app/models/white_label_agreement.rb +++ b/app/models/white_label_agreement.rb @@ -12,6 +12,11 @@ # The signed token in the gate link is minted with Lago's own MessageVerifier # (SECRET_KEY_BASE) — same mechanism as the customer portal — so no extra auth # infrastructure is needed and links self-expire. +# +# Each token also carries the agreement's current `token_nonce`. The nonce is +# rotated when the agreement is accepted and when it is reset, so a link only +# works for the state it was issued for: the emailed signing link stops working +# once the agreement is signed. class WhiteLabelAgreement < ApplicationRecord STATUSES = %w[pending accepted active superseded].freeze TOKEN_PURPOSE = "white_label_agreement" @@ -30,16 +35,31 @@ def self.verifier ActiveSupport::MessageVerifier.new(ENV.fetch("SECRET_KEY_BASE")) end - # Find an agreement from a signed gate token; returns nil if invalid/expired. + # Find an agreement from a signed gate token; returns nil if the token is + # invalid, expired, or was issued for a previous state of the agreement. def self.from_token(token) - id = verifier.verify(token, purpose: TOKEN_PURPOSE) - find_by(id:) + payload = verifier.verify(token, purpose: TOKEN_PURPOSE) + id, nonce = Array(payload) + agreement = find_by(id:) + return nil if agreement.nil? + + valid = if nonce.nil? + # Links issued before nonces existed carry only the id. They remain + # usable for signing only, until the agreement first changes state. + agreement.token_nonce.blank? && agreement.status == "pending" + else + agreement.token_nonce.present? && + ActiveSupport::SecurityUtils.secure_compare(agreement.token_nonce, nonce.to_s) + end + + valid ? agreement : nil rescue ActiveSupport::MessageVerifier::InvalidSignature nil end def signed_token - self.class.verifier.generate(id, purpose: TOKEN_PURPOSE, expires_in: TOKEN_TTL) + ensure_token_nonce! + self.class.verifier.generate([id, token_nonce], purpose: TOKEN_PURPOSE, expires_in: TOKEN_TTL) end # Full URL of the hosted MSA acceptance gate for this agreement. Used by the @@ -49,13 +69,26 @@ def self.gate_base_url ENV["LAGO_FRONT_URL"].presence || "http://localhost:3000").chomp("/") end + # Link to sign the agreement. Only available while the agreement is pending: + # once it is signed the signing link is no longer issued or accepted. def signing_url - "#{self.class.gate_base_url}/white-label/#{signed_token}" + return nil unless status == "pending" + + gate_url + end + + # Link that lets the signer resume the payment step of an accepted agreement + # (used as the Stripe checkout cancel URL). Bound to the post-acceptance nonce. + def payment_resume_url + return nil unless status == "accepted" + + gate_url end def accept!(signer:, request_ip:, user_agent:) update!( status: "accepted", + token_nonce: self.class.generate_token_nonce, accepted_at: Time.current, accepted_by_name: signer[:name], accepted_by_title: signer[:title], @@ -72,6 +105,7 @@ def accept!(signer:, request_ip:, user_agent:) def reset_to_pending!(reason:) update!( status: "pending", + token_nonce: self.class.generate_token_nonce, subscription_external_id: nil, accepted_at: nil, accepted_by_name: nil, @@ -90,4 +124,24 @@ def reset_to_pending!(reason:) def notify_email accepted_by_email.presence || customer.email end + + def self.generate_token_nonce + SecureRandom.urlsafe_base64(24) + end + + private + + def gate_url + "#{self.class.gate_base_url}/white-label/#{signed_token}" + end + + def ensure_token_nonce! + return if token_nonce.present? + + # Only set the nonce if nobody else did concurrently, then reload it so + # every caller signs with the stored value. + self.class.where(id:, token_nonce: nil).update_all(token_nonce: self.class.generate_token_nonce) # rubocop:disable Rails/SkipsModelValidations + self.token_nonce = self.class.where(id:).pick(:token_nonce) + clear_attribute_change(:token_nonce) + end end diff --git a/app/services/auth/okta/base_service.rb b/app/services/auth/okta/base_service.rb index c62e88327e41..8ed9b7865bc4 100644 --- a/app/services/auth/okta/base_service.rb +++ b/app/services/auth/okta/base_service.rb @@ -50,13 +50,19 @@ def query_okta_access_token redirect_uri: "#{ENV["LAGO_FRONT_URL"]}/auth/okta/callback" } - token_client = LagoHttpClient::Client.new("https://#{result.okta_integration.host}/oauth2/v1/token") + token_client = LagoHttpClient::Client.new( + "https://#{result.okta_integration.host}/oauth2/v1/token", + restrict_outbound: true + ) response = token_client.post_url_encoded(params, {}) result.okta_access_token = response["access_token"] end def check_userinfo(email) - userinfo_client = LagoHttpClient::Client.new("https://#{result.okta_integration.host}/oauth2/v1/userinfo") + userinfo_client = LagoHttpClient::Client.new( + "https://#{result.okta_integration.host}/oauth2/v1/userinfo", + restrict_outbound: true + ) userinfo_headers = {"Authorization" => "Bearer #{result.okta_access_token}"} response = userinfo_client.get(headers: userinfo_headers) diff --git a/app/services/customer_portal/available_plans_query.rb b/app/services/customer_portal/available_plans_query.rb new file mode 100644 index 000000000000..a4a41c532eca --- /dev/null +++ b/app/services/customer_portal/available_plans_query.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module CustomerPortal + # Single source of truth for which plans a customer-portal user may see + # and subscribe to. Used by the `customerPortalAvailablePlans` resolver + # and by the portal subscription mutations, so the list shown in the UI + # and the plans accepted on the server always match. + # + # Rules: + # * top-level plans only (`parent_id: nil`) + # * `self_serve: true` + # * metadata `hidden_in_portal` is not "true" + # * when metadata `visible_to_products` is set, the customer must + # already have an active subscription in one of those products + class AvailablePlansQuery + def self.call(...) + new(...).call + end + + def self.find_plan(customer:, code:) + new(customer:).find_plan(code) + end + + def self.product_key_for(code) + code.to_s.split("-").first + end + + # SQL LIKE pattern matching subscriptions whose external_id ends with + # `-`, with wildcard characters escaped. + def self.external_id_suffix_pattern(product_key) + "%-#{ActiveRecord::Base.sanitize_sql_like(product_key.to_s)}" + end + + def initialize(customer:, product_key: nil, exclude_current: false) + @customer = customer + @product_key = product_key + @exclude_current = exclude_current + end + + def call + plans = base_scope.includes(:metadata).order(:amount_cents) + + if product_key.present? + plans = plans.where("code LIKE ?", "#{ActiveRecord::Base.sanitize_sql_like(product_key)}-%") + end + + if exclude_current + active_plan_codes = active_subscription_plan_codes + plans = plans.where.not(code: active_plan_codes) if active_plan_codes.any? + end + + plans.to_a.select { |plan| visible?(plan) } + end + + # Returns the plan with the given code if (and only if) it is available + # to this customer, otherwise nil. + def find_plan(code) + return nil if code.blank? + + plan = base_scope.includes(:metadata).find_by(code:) + return nil if plan.nil? + + visible?(plan) ? plan : nil + end + + private + + attr_reader :customer, :product_key, :exclude_current + + def base_scope + customer.organization.plans.where(parent_id: nil, self_serve: true) + end + + def visible?(plan) + meta = plan.metadata&.value || {} + return false if meta["hidden_in_portal"].to_s == "true" + + allowed = meta["visible_to_products"].to_s.split(",").map(&:strip).reject(&:empty?) + return true if allowed.empty? + + allowed.intersect?(customer_product_keys) + end + + def active_subscription_plan_codes + @active_subscription_plan_codes ||= customer.subscriptions.active.joins(:plan).pluck("plans.code") + end + + def customer_product_keys + @customer_product_keys ||= active_subscription_plan_codes.map { |c| self.class.product_key_for(c) }.uniq + end + end +end diff --git a/app/services/customer_portal/generate_url_service.rb b/app/services/customer_portal/generate_url_service.rb index e2f2a6c992ed..f32e83ba11fd 100644 --- a/app/services/customer_portal/generate_url_service.rb +++ b/app/services/customer_portal/generate_url_service.rb @@ -19,7 +19,9 @@ def call # return_to lets the host app self-heal a stale portal tab: when the # JWT expires, lago-front bounces back to this URL with ?refresh=1 so # the host app's billing page can mint a fresh URL. - if return_to.present? + # It is not covered by the signed token, so only hosts listed in + # LAGO_PORTAL_RETURN_TO_HOSTS are kept; anything else is dropped. + if allowed_return_to? url += "?return_to=#{CGI.escape(return_to)}" end result.url = url @@ -30,5 +32,28 @@ def call private attr_reader :customer, :return_to + + def allowed_return_to? + return false if return_to.blank? + + uri = URI.parse(return_to.to_s) + return false unless %w[http https].include?(uri.scheme&.downcase) + return false if uri.userinfo.present? || uri.host.blank? + + host = uri.host.downcase + allowed_return_to_hosts.any? do |allowed| + if allowed.start_with?("*.") + host.end_with?(allowed.delete_prefix("*")) + else + host == allowed + end + end + rescue URI::InvalidURIError + false + end + + def allowed_return_to_hosts + ENV.fetch("LAGO_PORTAL_RETURN_TO_HOSTS", "").split(",").map { |h| h.strip.downcase }.reject(&:empty?) + end end end diff --git a/app/services/webhooks/send_http_service.rb b/app/services/webhooks/send_http_service.rb index e67e5715ac59..5f74926cdccc 100644 --- a/app/services/webhooks/send_http_service.rb +++ b/app/services/webhooks/send_http_service.rb @@ -46,7 +46,8 @@ def http_client webhook.webhook_endpoint.webhook_url, read_timeout: timeout_seconds, write_timeout: timeout_seconds, - open_timeout: timeout_seconds + open_timeout: timeout_seconds, + restrict_outbound: true ) end diff --git a/db/migrate/20261017000000_add_white_label_to_full_access_api_keys.rb b/db/migrate/20261017000000_add_white_label_to_full_access_api_keys.rb new file mode 100644 index 000000000000..8a2244b50e90 --- /dev/null +++ b/db/migrate/20261017000000_add_white_label_to_full_access_api_keys.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# `white_label` is a new API key permission resource. Keys that currently +# hold full read/write access on every other resource are granted it too, +# so existing full-access keys keep working. Restricted keys are left as is. +class AddWhiteLabelToFullAccessApiKeys < ActiveRecord::Migration[8.0] + PREVIOUS_RESOURCES = %w[ + activity_log add_on analytic api_log billable_metric coupon applied_coupon credit_note customer_usage + customer event fee invoice organization payment payment_receipt payment_request payment_method plan subscription lifetime_usage + tax wallet wallet_transaction webhook_endpoint webhook_jwt_public_key invoice_custom_section + billing_entity alert feature security_log quote + ].freeze + + def up + full_access = PREVIOUS_RESOURCES.map do |resource| + "COALESCE(permissions -> #{connection.quote(resource)}, '[]'::jsonb) @> '[\"read\", \"write\"]'::jsonb" + end.join(" AND ") + + safety_assured do + execute(<<~SQL.squish) + UPDATE api_keys + SET permissions = permissions || '{"white_label": ["read", "write"]}'::jsonb + WHERE NOT (permissions ? 'white_label') + AND #{full_access} + SQL + end + end + + def down + safety_assured do + execute("UPDATE api_keys SET permissions = permissions - 'white_label' WHERE permissions ? 'white_label'") + end + end +end diff --git a/db/migrate/20261017000001_add_token_nonce_to_white_label_agreements.rb b/db/migrate/20261017000001_add_token_nonce_to_white_label_agreements.rb new file mode 100644 index 000000000000..0edd442b9ea3 --- /dev/null +++ b/db/migrate/20261017000001_add_token_nonce_to_white_label_agreements.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Gate links embed the agreement's token_nonce, which is rotated on acceptance +# and reset so a link is only valid for the state it was issued for. +class AddTokenNonceToWhiteLabelAgreements < ActiveRecord::Migration[8.0] + def change + add_column :white_label_agreements, :token_nonce, :string + end +end diff --git a/lib/lago_http_client/lago_http_client.rb b/lib/lago_http_client/lago_http_client.rb index f46259c50d4d..8d30edf72735 100644 --- a/lib/lago_http_client/lago_http_client.rb +++ b/lib/lago_http_client/lago_http_client.rb @@ -3,6 +3,7 @@ require "net/http" require "json" +require "lago_http_client/outbound_url_guard" require "lago_http_client/client" require "lago_http_client/session_client" require "lago_http_client/http_error" diff --git a/lib/lago_http_client/lago_http_client/client.rb b/lib/lago_http_client/lago_http_client/client.rb index e259a810f3c9..239ab1218c50 100644 --- a/lib/lago_http_client/lago_http_client/client.rb +++ b/lib/lago_http_client/lago_http_client/client.rb @@ -10,9 +10,23 @@ class Client attr_reader :uri, :retries_on - def initialize(url, open_timeout: nil, read_timeout: nil, write_timeout: nil, retries_on: []) + # restrict_outbound: set to true when the URL comes from a customer + # (webhook endpoints, integration hosts). The host is resolved and + # checked by OutboundUrlGuard, and the connection is pinned to the + # checked address (the Host header and TLS SNI still use the hostname). + # Redirects are never followed by this client. + def initialize(url, open_timeout: nil, read_timeout: nil, write_timeout: nil, retries_on: [], restrict_outbound: false) @uri = URI(url) - @http_client = Net::HTTP.new(uri.host, uri.port) + + if restrict_outbound + pinned_ip = ::LagoHttpClient::OutboundUrlGuard.resolve!(uri) + # No proxy: the connection must go to the checked address. + @http_client = Net::HTTP.new(uri.hostname, uri.port, nil) + @http_client.ipaddr = pinned_ip if pinned_ip + else + @http_client = Net::HTTP.new(uri.host, uri.port) + end + @http_client.open_timeout = open_timeout if open_timeout.present? @http_client.read_timeout = read_timeout if read_timeout.present? @http_client.write_timeout = write_timeout if write_timeout.present? diff --git a/lib/lago_http_client/lago_http_client/outbound_url_guard.rb b/lib/lago_http_client/lago_http_client/outbound_url_guard.rb new file mode 100644 index 000000000000..0c311ea2aaf9 --- /dev/null +++ b/lib/lago_http_client/lago_http_client/outbound_url_guard.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "ipaddr" +require "socket" +require "uri" + +module LagoHttpClient + # Restricts outbound HTTP calls made to customer-supplied URLs (webhook + # endpoints, integration hosts, ...) to public internet destinations. + # + # The host is resolved once, every resolved address is checked against + # non-public ranges, and the caller is expected to pin the connection to + # the returned address so the checked address is the one actually used. + # + # Set LAGO_OUTBOUND_ALLOW_PRIVATE=true to disable the checks in local + # development. The flag is ignored when Rails runs in production. + class OutboundUrlGuard + class DisallowedDestinationError < SocketError; end + + ALLOWED_SCHEMES = %w[http https].freeze + + BLOCKED_IPV4_RANGES = %w[ + 0.0.0.0/8 + 10.0.0.0/8 + 100.64.0.0/10 + 127.0.0.0/8 + 169.254.0.0/16 + 172.16.0.0/12 + 192.0.0.0/24 + 192.0.2.0/24 + 192.88.99.0/24 + 192.168.0.0/16 + 198.18.0.0/15 + 198.51.100.0/24 + 203.0.113.0/24 + 224.0.0.0/4 + 240.0.0.0/4 + 255.255.255.255/32 + ].map { |cidr| IPAddr.new(cidr) }.freeze + + BLOCKED_IPV6_RANGES = %w[ + ::/96 + ::ffff:0:0/96 + 64:ff9b:1::/48 + 100::/64 + 2001::/23 + 2001:db8::/32 + fc00::/7 + fe80::/10 + fec0::/10 + ff00::/8 + ].map { |cidr| IPAddr.new(cidr) }.freeze + + # IPv6 prefixes that embed an IPv4 address which must itself be public. + NAT64_PREFIX = IPAddr.new("64:ff9b::/96") + SIX_TO_FOUR_PREFIX = IPAddr.new("2002::/16") + IPV4_MAPPED_PREFIX = IPAddr.new("::ffff:0:0/96") + + HOSTNAME_REGEX = /\A(?=.{1,253}\z)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\z/i + + class << self + # Validates the URL and returns the IP address (String) the connection + # must be pinned to, or nil when checks are disabled. + # Raises DisallowedDestinationError otherwise. + def resolve!(url) + uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s) + + unless ALLOWED_SCHEMES.include?(uri.scheme&.downcase) && uri.hostname.present? + raise DisallowedDestinationError, "outbound destination is not allowed" + end + + return nil if allow_private? + + host = uri.hostname + addresses = ip_literal?(host) ? [host] : resolve_host(host) + + if addresses.blank? + raise DisallowedDestinationError, "outbound destination could not be resolved" + end + + ips = addresses.map { |address| IPAddr.new(address) } + if ips.any? { |ip| blocked_ip?(ip) } + raise DisallowedDestinationError, "outbound destination is not allowed" + end + + ips.first.to_s + rescue URI::InvalidURIError, IPAddr::InvalidAddressError + raise DisallowedDestinationError, "outbound destination is not allowed" + end + + # Boolean helper for model validations. + def allowed?(url) + resolve!(url) + true + rescue DisallowedDestinationError + false + end + + def valid_hostname?(host) + host.is_a?(String) && HOSTNAME_REGEX.match?(host) + end + + def blocked_ip?(ip) + ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr) + + if ip.ipv4? + return BLOCKED_IPV4_RANGES.any? { |range| range.include?(ip) } + end + + embedded = embedded_ipv4(ip) + return blocked_ip?(embedded) if embedded + + BLOCKED_IPV6_RANGES.any? { |range| range.include?(ip) } + end + + def allow_private? + return false if ENV["LAGO_OUTBOUND_ALLOW_PRIVATE"].to_s.downcase != "true" + return false if defined?(Rails) && Rails.env.production? + + true + end + + def resolve_host(host) + Addrinfo.getaddrinfo(host, nil, nil, :STREAM).map(&:ip_address).uniq + rescue SocketError + [] + end + + private + + def ip_literal?(host) + IPAddr.new(host) + true + rescue IPAddr::InvalidAddressError + false + end + + def embedded_ipv4(ip) + if IPV4_MAPPED_PREFIX.include?(ip) + ip.native + elsif NAT64_PREFIX.include?(ip) + IPAddr.new(ip.to_i & 0xffff_ffff, Socket::AF_INET) + elsif SIX_TO_FOUR_PREFIX.include?(ip) + IPAddr.new((ip.to_i >> 80) & 0xffff_ffff, Socket::AF_INET) + end + end + end + end +end diff --git a/spec/factories/white_label_agreements.rb b/spec/factories/white_label_agreements.rb new file mode 100644 index 000000000000..af961261cb62 --- /dev/null +++ b/spec/factories/white_label_agreements.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :white_label_agreement do + customer + organization { customer.organization } + plan_code { "white-label" } + msa_version { "v1" } + terms_version { "v1" } + status { "pending" } + end +end diff --git a/spec/graphql/mutations/customer_portal/subscriptions/change_plan_spec.rb b/spec/graphql/mutations/customer_portal/subscriptions/change_plan_spec.rb new file mode 100644 index 000000000000..f838465dd140 --- /dev/null +++ b/spec/graphql/mutations/customer_portal/subscriptions/change_plan_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CustomerPortal::Subscriptions::ChangePlan do + subject(:result) do + execute_graphql( + customer_portal_user: customer, + query: mutation, + variables: {input: {planCode: plan_code}} + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, currency: "EUR") } + let(:current_plan) { create(:plan, organization:, code: "aistack-basic", amount_cents: 100) } + let(:target_plan) { create(:plan, organization:, code: "aistack-pro", amount_cents: 200) } + let(:plan_code) { target_plan.code } + let!(:subscription) do + create(:subscription, customer:, organization:, plan: current_plan, external_id: "#{customer.external_id}-aistack") + end + + let(:mutation) do + <<~GQL + mutation($input: ChangeCustomerPortalSubscriptionPlanInput!) { + changeCustomerPortalSubscriptionPlan(input: $input) { id plan { code } } + } + GQL + end + + it_behaves_like "requires a customer portal user" + + it "switches to an available plan" do + data = result["data"]["changeCustomerPortalSubscriptionPlan"] + + expect(data["plan"]["code"]).to eq("aistack-pro") + end + + shared_examples "a rejected plan" do + it "does not change the subscription" do + expect_graphql_error(result:, message: "plan_not_found") + expect(subscription.reload.plan).to eq(current_plan) + expect(customer.subscriptions.count).to eq(1) + end + end + + context "when the target plan is not self-serve" do + let(:target_plan) { create(:plan, organization:, code: "aistack-enterprise", self_serve: false) } + + it_behaves_like "a rejected plan" + end + + context "when the target plan is hidden in the portal" do + before { create(:item_metadata, organization:, owner: target_plan, value: {"hidden_in_portal" => "true"}) } + + it_behaves_like "a rejected plan" + end + + context "when the target plan is restricted to other products" do + before { create(:item_metadata, organization:, owner: target_plan, value: {"visible_to_products" => "memory"}) } + + it_behaves_like "a rejected plan" + end + + context "when the product key contains LIKE wildcards" do + let(:plan_code) { "%-pro" } + + it "does not match unrelated subscriptions" do + expect_graphql_error(result:, message: "no_active_subscription_for_product") + end + end +end diff --git a/spec/graphql/mutations/customer_portal/subscriptions/create_spec.rb b/spec/graphql/mutations/customer_portal/subscriptions/create_spec.rb new file mode 100644 index 000000000000..4970d58cb46f --- /dev/null +++ b/spec/graphql/mutations/customer_portal/subscriptions/create_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CustomerPortal::Subscriptions::Create do + subject(:result) do + execute_graphql( + customer_portal_user: customer, + query: mutation, + variables: {input: {planCode: plan_code}} + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, currency: "EUR") } + let(:plan) { create(:plan, organization:, code: "aistack-basic") } + let(:plan_code) { plan.code } + + let(:mutation) do + <<~GQL + mutation($input: CreateCustomerPortalSubscriptionInput!) { + createCustomerPortalSubscription(input: $input) { id externalId plan { code } } + } + GQL + end + + it_behaves_like "requires a customer portal user" + + it "creates a subscription for an available plan" do + data = result["data"]["createCustomerPortalSubscription"] + + expect(data["plan"]["code"]).to eq("aistack-basic") + expect(data["externalId"]).to eq("#{customer.external_id}-aistack") + end + + shared_examples "a rejected plan" do + it "does not create a subscription" do + expect { result }.not_to change(Subscription, :count) + expect_graphql_error(result:, message: "plan_not_found") + end + end + + context "when the plan is not self-serve" do + let(:plan) { create(:plan, organization:, code: "aistack-enterprise", self_serve: false) } + + it_behaves_like "a rejected plan" + end + + context "when the plan is hidden in the portal" do + before { create(:item_metadata, organization:, owner: plan, value: {"hidden_in_portal" => "true"}) } + + it_behaves_like "a rejected plan" + end + + context "when the plan is restricted to products the customer does not have" do + before { create(:item_metadata, organization:, owner: plan, value: {"visible_to_products" => "memory"}) } + + it_behaves_like "a rejected plan" + end + + context "when the plan belongs to another organization" do + let(:plan) { create(:plan, code: "aistack-basic") } + + it_behaves_like "a rejected plan" + end +end diff --git a/spec/graphql/mutations/customer_portal/subscriptions/terminate_spec.rb b/spec/graphql/mutations/customer_portal/subscriptions/terminate_spec.rb new file mode 100644 index 000000000000..b1a887737ca4 --- /dev/null +++ b/spec/graphql/mutations/customer_portal/subscriptions/terminate_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CustomerPortal::Subscriptions::Terminate do + subject(:result) do + execute_graphql( + customer_portal_user: customer, + query: mutation, + variables: {input: {productKey: product_key}} + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, code: "aistack-basic") } + let!(:subscription) do + create(:subscription, customer:, organization:, plan:, external_id: "#{customer.external_id}-aistack") + end + + let(:mutation) do + <<~GQL + mutation($input: TerminateCustomerPortalSubscriptionInput!) { + terminateCustomerPortalSubscription(input: $input) { id status } + } + GQL + end + + it_behaves_like "requires a customer portal user" + + context "with the product key of an active subscription" do + let(:product_key) { "aistack" } + + it "terminates the subscription" do + expect(result["data"]["terminateCustomerPortalSubscription"]["status"]).to eq("terminated") + expect(subscription.reload).to be_terminated + end + end + + %w[% _ aistac_ %stack].each do |key| + context "with the wildcard product key #{key.inspect}" do + let(:product_key) { key } + + it "does not terminate anything" do + expect_graphql_error(result:, message: "no_active_subscription_for_product") + expect(subscription.reload).to be_active + end + end + end +end diff --git a/spec/graphql/mutations/organizations/create_spec.rb b/spec/graphql/mutations/organizations/create_spec.rb new file mode 100644 index 000000000000..01382070ed0a --- /dev/null +++ b/spec/graphql/mutations/organizations/create_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Organizations::Create do + subject(:result) do + execute_graphql( + current_user: user, + query: mutation, + variables: {input: {name: "New Org"}} + ) + end + + let(:user) { create(:user) } + let(:mutation) do + <<~GQL + mutation($input: CreateOrganizationInput!) { + createOrganization(input: $input) { id name } + } + GQL + end + + around do |example| + keys = %w[LAGO_DISABLE_SIGNUP LAGO_MAX_ORGANIZATIONS_PER_USER] + previous = keys.index_with { |k| ENV[k] } + example.run + ensure + previous.each { |k, v| ENV[k] = v } + end + + before { create(:role, :admin) } + + it_behaves_like "requires current user" + + it "creates an organization with the user as admin" do + expect { result }.to change(Organization, :count).by(1) + + organization = Organization.find(result["data"]["createOrganization"]["id"]) + expect(organization.name).to eq("New Org") + expect(user.memberships.active.admins.where(organization:)).to exist + end + + context "when signup is disabled" do + before { ENV["LAGO_DISABLE_SIGNUP"] = "true" } + + context "when the user is not admin of any organization" do + before { create(:membership, user:) } + + it "is not allowed" do + expect { result }.not_to change(Organization, :count) + expect_graphql_error(result:, message: "signup_disabled") + end + end + + context "when the user is admin of an organization" do + before { create(:membership, user:, roles: [:admin]) } + + it "creates the organization" do + expect { result }.to change(Organization, :count).by(1) + end + end + end + + context "when the user reached the organization limit" do + before do + ENV["LAGO_MAX_ORGANIZATIONS_PER_USER"] = "2" + create_list(:membership, 2, user:, roles: [:admin]) + end + + it "is forbidden" do + expect { result }.not_to change(Organization, :count) + expect_graphql_error(result:, message: "organization_limit_reached") + end + end +end diff --git a/spec/lib/lago_http_client/client_spec.rb b/spec/lib/lago_http_client/client_spec.rb index 20e4810cbf13..8ea17e5ee8e0 100644 --- a/spec/lib/lago_http_client/client_spec.rb +++ b/spec/lib/lago_http_client/client_spec.rb @@ -58,6 +58,50 @@ expect(client.retries_on).to eq [Net::OpenTimeout, Net::ReadTimeout] end end + + context "with restrict_outbound option" do + let(:client_options) { {restrict_outbound: true} } + + it "pins the connection to the resolved address and keeps the hostname" do + http = client.send(:http_client) + + expect(http.ipaddr).to eq("93.184.215.14") + expect(http.address).to eq("example.com") + expect(http.proxy?).to be(false) + end + + context "when the host resolves to a non-public address" do + before { allow(LagoHttpClient::OutboundUrlGuard).to receive(:resolve_host).and_return(["169.254.169.254"]) } + + it "refuses to build the client" do + expect { client }.to raise_error(LagoHttpClient::OutboundUrlGuard::DisallowedDestinationError) + end + end + + context "when the URL is an internal IP literal" do + let(:url) { "http://10.0.0.1/api" } + + it "refuses to build the client" do + expect { client }.to raise_error(LagoHttpClient::OutboundUrlGuard::DisallowedDestinationError) + end + end + + context "when the destination answers with a redirect" do + before { stub_request(:post, url).to_return(status: 302, headers: {"Location" => "http://169.254.169.254/"}) } + + it "does not follow it" do + expect { client.post_with_response({}, {}) }.to raise_error(LagoHttpClient::HttpError) + expect(a_request(:get, "http://169.254.169.254/")).not_to have_been_made + end + end + end + + context "without restrict_outbound option" do + it "does not resolve or pin the host" do + expect(client.send(:http_client).ipaddr).to be_nil + expect(LagoHttpClient::OutboundUrlGuard).not_to have_received(:resolve_host) + end + end end describe "timeouts" do diff --git a/spec/lib/lago_http_client/outbound_url_guard_spec.rb b/spec/lib/lago_http_client/outbound_url_guard_spec.rb new file mode 100644 index 000000000000..c7b9636f004c --- /dev/null +++ b/spec/lib/lago_http_client/outbound_url_guard_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoHttpClient::OutboundUrlGuard do + describe ".resolve!" do + context "when the host resolves to a public address" do + before { allow(described_class).to receive(:resolve_host).with("hooks.example.com").and_return(["93.184.215.14"]) } + + it "returns the address to pin the connection to" do + expect(described_class.resolve!("https://hooks.example.com/path")).to eq("93.184.215.14") + end + end + + [ + "127.0.0.1", "127.1.2.3", "0.0.0.0", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", + "169.254.169.254", "100.64.0.1", "100.100.100.200", "192.0.0.192", "198.18.0.1", "224.0.0.1", + "255.255.255.255", "::1", "::", "fe80::1", "fc00::1", "fd00:ec2::254", "ff02::1", + "::ffff:127.0.0.1", "::ffff:169.254.169.254", "::ffff:a9fe:a9fe", "64:ff9b::a9fe:a9fe", "2002:7f00:1::1" + ].each do |address| + it "rejects #{address} when it is the resolved address" do + allow(described_class).to receive(:resolve_host).and_return([address]) + + expect { described_class.resolve!("https://internal.example.com/") } + .to raise_error(described_class::DisallowedDestinationError) + end + end + + it "rejects IP literals in non-public ranges without resolving" do + expect { described_class.resolve!("http://127.0.0.1:3000/") }.to raise_error(described_class::DisallowedDestinationError) + expect { described_class.resolve!("http://[::1]/") }.to raise_error(described_class::DisallowedDestinationError) + expect { described_class.resolve!("http://[::ffff:10.0.0.1]/") }.to raise_error(described_class::DisallowedDestinationError) + expect(described_class).not_to have_received(:resolve_host) + end + + it "accepts public IP literals" do + expect(described_class.resolve!("https://8.8.8.8/")).to eq("8.8.8.8") + expect(described_class.resolve!("https://[2606:4700:4700::1111]/")).to eq("2606:4700:4700::1111") + end + + it "rejects the host when any resolved address is not public" do + allow(described_class).to receive(:resolve_host).and_return(["93.184.215.14", "10.0.0.5"]) + + expect { described_class.resolve!("https://mixed.example.com/") }.to raise_error(described_class::DisallowedDestinationError) + end + + it "rejects hosts that do not resolve" do + allow(described_class).to receive(:resolve_host).and_return([]) + + expect { described_class.resolve!("https://nowhere.example.com/") }.to raise_error(described_class::DisallowedDestinationError) + end + + it "rejects non-HTTP schemes and malformed URLs" do + ["ftp://example.com/", "file:///etc/hosts", "gopher://example.com", "not a url", "http://"].each do |url| + expect { described_class.resolve!(url) }.to raise_error(described_class::DisallowedDestinationError) + end + end + + context "when LAGO_OUTBOUND_ALLOW_PRIVATE is enabled" do + around do |example| + previous = ENV["LAGO_OUTBOUND_ALLOW_PRIVATE"] + ENV["LAGO_OUTBOUND_ALLOW_PRIVATE"] = "true" + example.run + ensure + ENV["LAGO_OUTBOUND_ALLOW_PRIVATE"] = previous + end + + it "allows private destinations outside production" do + expect(described_class.resolve!("http://127.0.0.1:3000/")).to be_nil + end + + it "is ignored in production" do + allow(Rails.env).to receive(:production?).and_return(true) + + expect { described_class.resolve!("http://127.0.0.1:3000/") }.to raise_error(described_class::DisallowedDestinationError) + end + end + end + + describe ".allowed?" do + it "returns a boolean" do + expect(described_class.allowed?("https://hooks.example.com/")).to be(true) + expect(described_class.allowed?("http://169.254.169.254/latest/meta-data/")).to be(false) + end + end + + describe ".valid_hostname?" do + it "accepts plain hostnames only" do + expect(described_class.valid_hostname?("acme.okta.com")).to be(true) + expect(described_class.valid_hostname?("acme.okta.com/path")).to be(false) + expect(described_class.valid_hostname?("user@acme.okta.com")).to be(false) + expect(described_class.valid_hostname?("acme.okta.com:8080")).to be(false) + expect(described_class.valid_hostname?(nil)).to be(false) + end + end +end diff --git a/spec/models/integrations/okta_integration_spec.rb b/spec/models/integrations/okta_integration_spec.rb index 08363715c275..4c0cb2531df4 100644 --- a/spec/models/integrations/okta_integration_spec.rb +++ b/spec/models/integrations/okta_integration_spec.rb @@ -38,6 +38,27 @@ expect(okta_integration).to be_valid end + context "when host is not a plain hostname" do + before { okta_integration.host = "acme.okta.com/redirect?x=" } + + it "is invalid" do + expect(okta_integration).not_to be_valid + expect(okta_integration.errors).to include(:host) + end + end + + context "when host resolves to a non-public address" do + before do + allow(LagoHttpClient::OutboundUrlGuard).to receive(:resolve_host).with("okta.internal.test").and_return(["127.0.0.1"]) + okta_integration.host = "okta.internal.test" + end + + it "is invalid" do + expect(okta_integration).not_to be_valid + expect(okta_integration.errors).to include(:host) + end + end + context "when domain already exists" do before { create(:okta_integration) } diff --git a/spec/models/webhook_endpoint_spec.rb b/spec/models/webhook_endpoint_spec.rb index 1be2cfc70a18..09261f3eca8f 100644 --- a/spec/models/webhook_endpoint_spec.rb +++ b/spec/models/webhook_endpoint_spec.rb @@ -60,6 +60,29 @@ end end + context "when webhook url points to a non-public address" do + ["http://127.0.0.1/hook", "http://169.254.169.254/latest", "http://[::1]/", "https://10.0.0.1/"].each do |url| + it "is invalid for #{url}" do + webhook_endpoint.webhook_url = url + + expect(webhook_endpoint).not_to be_valid + expect(webhook_endpoint.errors.where(:webhook_url, :url_invalid)).to be_present + end + end + end + + context "when webhook url hostname resolves to a non-public address" do + before do + allow(LagoHttpClient::OutboundUrlGuard).to receive(:resolve_host).with("internal.example.com").and_return(["10.0.0.7"]) + webhook_endpoint.webhook_url = "https://internal.example.com/hook" + end + + it "is invalid" do + expect(webhook_endpoint).not_to be_valid + expect(webhook_endpoint.errors.where(:webhook_url, :url_invalid)).to be_present + end + end + describe "event_type validity" do context "when nil" do before { webhook_endpoint.event_types = nil } diff --git a/spec/models/white_label_agreement_spec.rb b/spec/models/white_label_agreement_spec.rb new file mode 100644 index 000000000000..4bd64a812d98 --- /dev/null +++ b/spec/models/white_label_agreement_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WhiteLabelAgreement do + let(:agreement) { create(:white_label_agreement) } + let(:signer) { {name: "Jane", title: "CEO", email: "jane@example.com"} } + + def token_from(url) + url.split("/white-label/").last + end + + describe ".from_token" do + it "finds a pending agreement from its signing link" do + expect(described_class.from_token(token_from(agreement.signing_url))).to eq(agreement) + end + + it "returns nil for tampered tokens" do + expect(described_class.from_token("garbage")).to be_nil + end + + it "invalidates the signing link once the agreement is accepted" do + token = token_from(agreement.signing_url) + agreement.accept!(signer:, request_ip: "1.2.3.4", user_agent: "rspec") + + expect(described_class.from_token(token)).to be_nil + end + + it "accepts the post-acceptance payment link" do + agreement.accept!(signer:, request_ip: "1.2.3.4", user_agent: "rspec") + + expect(described_class.from_token(token_from(agreement.payment_resume_url))).to eq(agreement) + end + + it "invalidates post-acceptance links when the agreement is reset" do + agreement.accept!(signer:, request_ip: "1.2.3.4", user_agent: "rspec") + token = token_from(agreement.payment_resume_url) + agreement.reset_to_pending!(reason: "manual_operator") + + expect(described_class.from_token(token)).to be_nil + expect(described_class.from_token(token_from(agreement.signing_url))).to eq(agreement) + end + + context "with a link issued before nonces existed" do + let(:legacy_token) do + described_class.verifier.generate(agreement.id, purpose: described_class::TOKEN_PURPOSE, expires_in: 1.day) + end + + it "is accepted while the agreement is pending" do + expect(described_class.from_token(legacy_token)).to eq(agreement) + end + + it "is refused once the agreement has a nonce" do + agreement.signing_url + + expect(described_class.from_token(legacy_token)).to be_nil + end + + it "is refused once the agreement is accepted" do + agreement.update!(status: "accepted") + + expect(described_class.from_token(legacy_token)).to be_nil + end + end + end + + describe "#signing_url" do + it "is only available while pending" do + expect(agreement.signing_url).to be_present + + agreement.accept!(signer:, request_ip: "1.2.3.4", user_agent: "rspec") + expect(agreement.signing_url).to be_nil + end + end + + describe "#payment_resume_url" do + it "is only available once accepted" do + expect(agreement.payment_resume_url).to be_nil + + agreement.accept!(signer:, request_ip: "1.2.3.4", user_agent: "rspec") + expect(agreement.payment_resume_url).to be_present + end + end +end diff --git a/spec/requests/api/v1/white_label_controller_spec.rb b/spec/requests/api/v1/white_label_controller_spec.rb new file mode 100644 index 000000000000..327be67ed47a --- /dev/null +++ b/spec/requests/api/v1/white_label_controller_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::WhiteLabelController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:agreement) { create(:white_label_agreement, customer:, organization:) } + + describe "GET /api/v1/white_label/agreements" do + subject { get_with_token(organization, "/api/v1/white_label/agreements") } + + before { agreement } + + it_behaves_like "requires API permission", "white_label", "read" + + it "lists agreements" do + subject + + expect(response).to have_http_status(:success) + expect(json[:white_label_agreements].map { |a| a[:lago_id] }).to eq([agreement.id]) + end + end + + describe "POST /api/v1/white_label/agreements/:id/reset" do + subject { post_with_token(organization, "/api/v1/white_label/agreements/#{agreement.id}/reset") } + + it_behaves_like "requires API permission", "white_label", "write" + end + + describe "POST /api/v1/white_label/provision" do + subject { post_with_token(organization, "/api/v1/white_label/provision", {external_id: "ext", plan_code: "missing"}) } + + it_behaves_like "requires API permission", "white_label", "write" + end +end diff --git a/spec/requests/white_label_controller_spec.rb b/spec/requests/white_label_controller_spec.rb new file mode 100644 index 000000000000..85da2799d047 --- /dev/null +++ b/spec/requests/white_label_controller_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WhiteLabelController, type: :request do + let(:agreement) { create(:white_label_agreement) } + let(:token) { agreement.signing_url.split("/white-label/").last } + let(:accept_params) { {accept: "1", signer_name: "Jane", signer_title: "CEO", signer_email: "jane@example.com"} } + + describe "GET /white-label/:token" do + it "renders the gate for a pending agreement" do + get "/white-label/#{token}" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("I accept the agreement") + end + end + + describe "POST /white-label/:token/accept" do + it "records the acceptance and continues to payment" do + post "/white-label/#{token}/accept", params: accept_params + + expect(response).to have_http_status(:found) + expect(agreement.reload.status).to eq("accepted") + end + + context "when the signing link is reused after acceptance" do + before { post "/white-label/#{token}/accept", params: accept_params } + + it "rejects GET requests" do + get "/white-label/#{token}" + + expect(response).to have_http_status(:not_found) + end + + it "rejects POST requests" do + post "/white-label/#{token}/accept", params: accept_params + + expect(response).to have_http_status(:not_found) + end + end + + context "when visiting the post-acceptance link and no checkout can be created" do + before { post "/white-label/#{token}/accept", params: accept_params } + + it "does not redirect to the customer portal" do + allow(CustomerPortal::GenerateUrlService).to receive(:call).and_call_original + resume_token = agreement.reload.payment_resume_url.split("/white-label/").last + + get "/white-label/#{resume_token}" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("has been signed") + expect(CustomerPortal::GenerateUrlService).not_to have_received(:call) + end + end + end +end diff --git a/spec/services/auth/okta/login_service_spec.rb b/spec/services/auth/okta/login_service_spec.rb index 71ee5dd5afea..b3aa342854f6 100644 --- a/spec/services/auth/okta/login_service_spec.rb +++ b/spec/services/auth/okta/login_service_spec.rb @@ -36,6 +36,15 @@ expect(UserDevices::RegisterService).to have_received(:call!).with(user: result.user) end + it "calls Okta with outbound restrictions enabled" do + service.call + + expect(LagoHttpClient::Client).to have_received(:new) + .with("https://#{okta_integration.host}/oauth2/v1/token", restrict_outbound: true) + expect(LagoHttpClient::Client).to have_received(:new) + .with("https://#{okta_integration.host}/oauth2/v1/userinfo", restrict_outbound: true) + end + it "creates user, membership and authenticate user" do result = service.call diff --git a/spec/services/customer_portal/available_plans_query_spec.rb b/spec/services/customer_portal/available_plans_query_spec.rb new file mode 100644 index 000000000000..138a4f13e293 --- /dev/null +++ b/spec/services/customer_portal/available_plans_query_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CustomerPortal::AvailablePlansQuery do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:basic) { create(:plan, organization:, code: "aistack-basic", amount_cents: 100) } + let(:pro) { create(:plan, organization:, code: "aistack-pro", amount_cents: 200) } + let(:enterprise) { create(:plan, organization:, code: "aistack-enterprise", self_serve: false) } + let(:hidden) { create(:plan, organization:, code: "aistack-hidden") } + let(:addon) { create(:plan, organization:, code: "growth-addon") } + let(:child) { create(:plan, organization:, code: "aistack-child", parent: basic) } + let(:other_org_plan) { create(:plan, code: "aistack-other") } + + before do + create(:item_metadata, organization:, owner: hidden, value: {"hidden_in_portal" => "true"}) + create(:item_metadata, organization:, owner: addon, value: {"visible_to_products" => "memory, aistack"}) + [basic, pro, enterprise, hidden, addon, child, other_org_plan] + end + + describe ".call" do + it "returns only self-serve, visible, top-level plans of the organization" do + expect(described_class.call(customer:)).to eq([basic, pro]) + end + + context "when the customer has a subscription in a product listed in visible_to_products" do + before { create(:subscription, customer:, plan: basic, external_id: "#{customer.external_id}-aistack") } + + it "includes the scoped plan" do + expect(described_class.call(customer:)).to contain_exactly(basic, pro, addon) + end + + it "excludes current plans when requested" do + expect(described_class.call(customer:, exclude_current: true)).to contain_exactly(pro, addon) + end + end + + it "filters by product key" do + expect(described_class.call(customer:, product_key: "growth")).to eq([]) + expect(described_class.call(customer:, product_key: "aistack")).to eq([basic, pro]) + end + + it "treats LIKE wildcards in the product key literally" do + expect(described_class.call(customer:, product_key: "%")).to eq([]) + expect(described_class.call(customer:, product_key: "aistac_")).to eq([]) + end + end + + describe ".find_plan" do + it "returns an available plan" do + expect(described_class.find_plan(customer:, code: "aistack-pro")).to eq(pro) + end + + it "returns nil for plans that are not offered in the portal" do + %w[aistack-enterprise aistack-hidden growth-addon aistack-child aistack-other unknown].each do |code| + expect(described_class.find_plan(customer:, code:)).to be_nil, "expected #{code} to be unavailable" + end + end + end + + describe ".external_id_suffix_pattern" do + it "escapes LIKE wildcards" do + expect(described_class.external_id_suffix_pattern("a%b_c")).to eq("%-a\\%b\\_c") + end + end +end diff --git a/spec/services/customer_portal/generate_url_service_spec.rb b/spec/services/customer_portal/generate_url_service_spec.rb index a53c1817d682..13b05ae4772d 100644 --- a/spec/services/customer_portal/generate_url_service_spec.rb +++ b/spec/services/customer_portal/generate_url_service_spec.rb @@ -18,6 +18,58 @@ expect(public_authenticator.verify(message)).to eq(customer.id) end + context "with return_to" do + subject(:generate_url_service) { described_class.new(customer:, return_to:) } + + around do |example| + previous = ENV["LAGO_PORTAL_RETURN_TO_HOSTS"] + ENV["LAGO_PORTAL_RETURN_TO_HOSTS"] = "app.example.com, *.tenant.example.com" + example.run + ensure + ENV["LAGO_PORTAL_RETURN_TO_HOSTS"] = previous + end + + [ + "https://app.example.com/billing", + "https://acme.tenant.example.com/billing?x=1" + ].each do |url| + context "when it targets an allowed host (#{url})" do + let(:return_to) { url } + + it "appends it to the url" do + expect(generate_url_service.call.url).to end_with("?return_to=#{CGI.escape(url)}") + end + end + end + + [ + "https://evil.example.net/", + "https://app.example.com.evil.net/", + "https://app.example.com@evil.net/", + "javascript:alert(1)", + "//evil.example.net", + "https://tenant.example.com.evil.net/" + ].each do |url| + context "when it targets a host that is not allowed (#{url})" do + let(:return_to) { url } + + it "drops it" do + expect(generate_url_service.call.url).not_to include("return_to") + end + end + end + + context "when no hosts are configured" do + let(:return_to) { "https://app.example.com/billing" } + + before { ENV["LAGO_PORTAL_RETURN_TO_HOSTS"] = "" } + + it "drops it" do + expect(generate_url_service.call.url).not_to include("return_to") + end + end + end + context "when customer does not exist" do let(:customer) { nil } diff --git a/spec/services/webhooks/send_http_service_spec.rb b/spec/services/webhooks/send_http_service_spec.rb index e77a351b14d3..649b13faee71 100644 --- a/spec/services/webhooks/send_http_service_spec.rb +++ b/spec/services/webhooks/send_http_service_spec.rb @@ -35,6 +35,23 @@ end end + context "when the endpoint host resolves to a non-public address at send time" do + before do + webhook_endpoint + allow(LagoHttpClient::OutboundUrlGuard).to receive(:resolve_host).with("wh.test.com").and_return(["169.254.169.254"]) + allow(SendHttpWebhookJob).to receive(:set).and_return(class_double(SendHttpWebhookJob, perform_later: nil)) + WebMock.stub_request(:post, "https://wh.test.com").to_return(status: 200, body: "secret") + end + + it "does not send the request and marks the webhook as unsuccessful" do + service.call + + expect(WebMock).not_to have_requested(:post, "https://wh.test.com") + expect(webhook.reload.status).to eq "retrying" + expect(webhook.response).not_to include("secret") + end + end + context "when client returns an error" do let(:error_body) do { @@ -45,7 +62,7 @@ before do allow(LagoHttpClient::Client).to receive(:new) - .with(webhook.webhook_endpoint.webhook_url, read_timeout: expected_timeout_seconds, write_timeout: expected_timeout_seconds, open_timeout: expected_timeout_seconds) + .with(webhook.webhook_endpoint.webhook_url, read_timeout: expected_timeout_seconds, write_timeout: expected_timeout_seconds, open_timeout: expected_timeout_seconds, restrict_outbound: true) .and_return(lago_client) allow(lago_client).to receive(:post_with_response).and_raise( LagoHttpClient::HttpError.new(403, error_body.to_json, "") @@ -68,7 +85,7 @@ service.call expect(LagoHttpClient::Client).to have_received(:new) - .with(webhook.webhook_endpoint.webhook_url, read_timeout: expected_timeout_seconds, write_timeout: expected_timeout_seconds, open_timeout: expected_timeout_seconds) + .with(webhook.webhook_endpoint.webhook_url, read_timeout: expected_timeout_seconds, write_timeout: expected_timeout_seconds, open_timeout: expected_timeout_seconds, restrict_outbound: true) end end diff --git a/spec/support/outbound_url_guard_helper.rb b/spec/support/outbound_url_guard_helper.rb new file mode 100644 index 000000000000..4d898668fbb2 --- /dev/null +++ b/spec/support/outbound_url_guard_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# The test suite has no DNS access and factories use random hostnames. +# Resolve every hostname to a fixed public address by default; +# specs exercising the guard override `resolve_host` explicitly. +RSpec.configure do |config| + config.before do + allow(LagoHttpClient::OutboundUrlGuard).to receive(:resolve_host).and_return(["93.184.215.14"]) + end +end From 039d292b2a006b3f64ed98052dda9ebd53d6ff39 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Sat, 17 Oct 2026 11:37:21 -0400 Subject: [PATCH 2/4] Update structure.sql and model annotations Regenerated by running all migrations on a fresh database and dumping with db:schema:dump (pg_dump 15). --- app/models/metadata/item_metadata.rb | 14 +-- .../payment_providers/adyen_provider.rb | 22 +++-- app/models/payment_providers/base_provider.rb | 17 ++-- .../payment_providers/cashfree_provider.rb | 22 +++-- .../payment_providers/flutterwave_provider.rb | 22 +++-- .../payment_providers/gocardless_provider.rb | 22 +++-- .../payment_providers/moneyhash_provider.rb | 22 +++-- .../payment_providers/stripe_provider.rb | 22 +++-- app/models/plan.rb | 1 + app/models/white_label_agreement.rb | 39 ++++++++ db/structure.sql | 97 ++++++++++++++++++- 11 files changed, 223 insertions(+), 77 deletions(-) diff --git a/app/models/metadata/item_metadata.rb b/app/models/metadata/item_metadata.rb index 42e72a75e402..dce79e7505ed 100644 --- a/app/models/metadata/item_metadata.rb +++ b/app/models/metadata/item_metadata.rb @@ -44,13 +44,13 @@ def value_correctness # Table name: item_metadata # Database name: primary # -# id :uuid not null, primary key -# owner_type :string not null -# value :jsonb not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null -# owner_id :uuid not null +# id :uuid not null, primary key +# owner_type(Polymorphic owner type) :string not null +# value(item_metadata key-value pairs) :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id(Reference to the organization) :uuid not null +# owner_id(Polymorphic owner id) :uuid not null # # Indexes # diff --git a/app/models/payment_providers/adyen_provider.rb b/app/models/payment_providers/adyen_provider.rb index 20d6116ddd33..465cf76bba9e 100644 --- a/app/models/payment_providers/adyen_provider.rb +++ b/app/models/payment_providers/adyen_provider.rb @@ -36,19 +36,21 @@ def payment_type # Table name: payment_providers # Database name: primary # -# id :uuid not null, primary key -# code :string not null -# deleted_at :datetime -# name :string not null -# secrets :string -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# organization_id :uuid not null # # Indexes # +# index_payment_providers_on_billing_entity_id (billing_entity_id) # index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) # diff --git a/app/models/payment_providers/base_provider.rb b/app/models/payment_providers/base_provider.rb index 2625e829182f..cd2b960359ac 100644 --- a/app/models/payment_providers/base_provider.rb +++ b/app/models/payment_providers/base_provider.rb @@ -47,26 +47,25 @@ def determine_payment_status(payment_status) # Table name: payment_providers # Database name: primary # -# id :uuid not null, primary key -# code :string not null -# deleted_at :datetime -# name :string not null -# secrets :string -# settings :jsonb not null +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null # type :string not null # created_at :datetime not null # updated_at :datetime not null -# organization_id :uuid not null # billing_entity_id :uuid +# organization_id :uuid not null # # Indexes # +# index_payment_providers_on_billing_entity_id (billing_entity_id) # index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) -# index_payment_providers_on_billing_entity_id (billing_entity_id) # # Foreign Keys # -# fk_rails_... (billing_entity_id => billing_entities.id) # fk_rails_... (organization_id => organizations.id) # diff --git a/app/models/payment_providers/cashfree_provider.rb b/app/models/payment_providers/cashfree_provider.rb index a98286553596..14d07d89012b 100644 --- a/app/models/payment_providers/cashfree_provider.rb +++ b/app/models/payment_providers/cashfree_provider.rb @@ -29,19 +29,21 @@ def payment_type # Table name: payment_providers # Database name: primary # -# id :uuid not null, primary key -# code :string not null -# deleted_at :datetime -# name :string not null -# secrets :string -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# organization_id :uuid not null # # Indexes # +# index_payment_providers_on_billing_entity_id (billing_entity_id) # index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) # diff --git a/app/models/payment_providers/flutterwave_provider.rb b/app/models/payment_providers/flutterwave_provider.rb index 2bae06c8efb7..7705a947fe30 100644 --- a/app/models/payment_providers/flutterwave_provider.rb +++ b/app/models/payment_providers/flutterwave_provider.rb @@ -38,19 +38,21 @@ def generate_webhook_secret # Table name: payment_providers # Database name: primary # -# id :uuid not null, primary key -# code :string not null -# deleted_at :datetime -# name :string not null -# secrets :string -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# organization_id :uuid not null # # Indexes # +# index_payment_providers_on_billing_entity_id (billing_entity_id) # index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) # diff --git a/app/models/payment_providers/gocardless_provider.rb b/app/models/payment_providers/gocardless_provider.rb index 55e69c035e62..07171d9ce353 100644 --- a/app/models/payment_providers/gocardless_provider.rb +++ b/app/models/payment_providers/gocardless_provider.rb @@ -40,19 +40,21 @@ def payment_type # Table name: payment_providers # Database name: primary # -# id :uuid not null, primary key -# code :string not null -# deleted_at :datetime -# name :string not null -# secrets :string -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# organization_id :uuid not null # # Indexes # +# index_payment_providers_on_billing_entity_id (billing_entity_id) # index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) # diff --git a/app/models/payment_providers/moneyhash_provider.rb b/app/models/payment_providers/moneyhash_provider.rb index f4f2939b1f0c..26d707f3272c 100644 --- a/app/models/payment_providers/moneyhash_provider.rb +++ b/app/models/payment_providers/moneyhash_provider.rb @@ -63,19 +63,21 @@ def payable_payment_status(mh_status) # Table name: payment_providers # Database name: primary # -# id :uuid not null, primary key -# code :string not null -# deleted_at :datetime -# name :string not null -# secrets :string -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# organization_id :uuid not null # # Indexes # +# index_payment_providers_on_billing_entity_id (billing_entity_id) # index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) # diff --git a/app/models/payment_providers/stripe_provider.rb b/app/models/payment_providers/stripe_provider.rb index 2dcd366e3176..9f03694e3824 100644 --- a/app/models/payment_providers/stripe_provider.rb +++ b/app/models/payment_providers/stripe_provider.rb @@ -70,19 +70,21 @@ def stripe_request_options # Table name: payment_providers # Database name: primary # -# id :uuid not null, primary key -# code :string not null -# deleted_at :datetime -# name :string not null -# secrets :string -# settings :jsonb not null -# type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# organization_id :uuid not null # # Indexes # +# index_payment_providers_on_billing_entity_id (billing_entity_id) # index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) # diff --git a/app/models/plan.rb b/app/models/plan.rb index d1c77f101c98..2e7f56cc8562 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -165,6 +165,7 @@ def validate_code_unique # name :string not null # pay_in_advance :boolean default(FALSE), not null # pending_deletion :boolean default(FALSE), not null +# self_serve :boolean default(TRUE), not null # trial_period :float # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/white_label_agreement.rb b/app/models/white_label_agreement.rb index 42f95ff0d351..fd9234bcbb90 100644 --- a/app/models/white_label_agreement.rb +++ b/app/models/white_label_agreement.rb @@ -145,3 +145,42 @@ def ensure_token_nonce! clear_attribute_change(:token_nonce) end end + +# == Schema Information +# +# Table name: white_label_agreements +# Database name: primary +# +# id :uuid not null, primary key +# accepted_at :datetime +# accepted_by_email :string +# accepted_by_name :string +# accepted_by_title :string +# accepted_ip :string +# accepted_user_agent :string +# last_reset_at :datetime +# last_reset_reason :string +# msa_version :string not null +# plan_code :string not null +# reset_count :integer default(0), not null +# status :string default("pending"), not null +# terms_version :string not null +# token_nonce :string +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# subscription_external_id :string +# +# Indexes +# +# index_white_label_agreements_on_customer_id (customer_id) +# index_white_label_agreements_on_customer_unique (customer_id) UNIQUE WHERE ((status)::text <> 'superseded'::text) +# index_white_label_agreements_on_organization_id (organization_id) +# index_white_label_agreements_on_organization_id_and_status (organization_id,status) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/db/structure.sql b/db/structure.sql index 326c78108813..6f9ea06a5292 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -87,6 +87,7 @@ ALTER TABLE IF EXISTS ONLY public.lifetime_usages DROP CONSTRAINT IF EXISTS fk_r ALTER TABLE IF EXISTS ONLY public.wallet_transactions_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_b974dac270; ALTER TABLE IF EXISTS ONLY public.presentation_breakdowns DROP CONSTRAINT IF EXISTS fk_rails_b8f3cabc8e; ALTER TABLE IF EXISTS ONLY public.subscription_activation_rules DROP CONSTRAINT IF EXISTS fk_rails_b749d2045d; +ALTER TABLE IF EXISTS ONLY public.white_label_agreements DROP CONSTRAINT IF EXISTS fk_rails_b659c0957e; ALTER TABLE IF EXISTS ONLY public.entitlement_entitlements DROP CONSTRAINT IF EXISTS fk_rails_b61aa73940; ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_b50dc82c1e; ALTER TABLE IF EXISTS ONLY public.entitlement_subscription_feature_removals DROP CONSTRAINT IF EXISTS fk_rails_b3864df641; @@ -128,6 +129,7 @@ ALTER TABLE IF EXISTS ONLY public.usage_thresholds DROP CONSTRAINT IF EXISTS fk_ ALTER TABLE IF EXISTS ONLY public.usage_monitoring_alerts DROP CONSTRAINT IF EXISTS fk_rails_8c18828b53; ALTER TABLE IF EXISTS ONLY public.fixed_charges_taxes DROP CONSTRAINT IF EXISTS fk_rails_8c09ee2428; ALTER TABLE IF EXISTS ONLY public.invoice_metadata DROP CONSTRAINT IF EXISTS fk_rails_8bb5b094c4; +ALTER TABLE IF EXISTS ONLY public.white_label_agreements DROP CONSTRAINT IF EXISTS fk_rails_8a1c5737b0; ALTER TABLE IF EXISTS ONLY public.add_ons_taxes DROP CONSTRAINT IF EXISTS fk_rails_89e1020aca; ALTER TABLE IF EXISTS ONLY public.entitlement_entitlement_values DROP CONSTRAINT IF EXISTS fk_rails_8887954ec7; ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_885dc100ef; @@ -332,6 +334,10 @@ SELECT NULL::jsonb AS filters_grouped_by; DROP INDEX IF EXISTS public.unique_default_payment_method_per_customer; DROP INDEX IF EXISTS public.index_wt_invoice_custom_sections_unique; +DROP INDEX IF EXISTS public.index_white_label_agreements_on_organization_id_and_status; +DROP INDEX IF EXISTS public.index_white_label_agreements_on_organization_id; +DROP INDEX IF EXISTS public.index_white_label_agreements_on_customer_unique; +DROP INDEX IF EXISTS public.index_white_label_agreements_on_customer_id; DROP INDEX IF EXISTS public.index_webhooks_on_webhook_endpoint_id; DROP INDEX IF EXISTS public.index_webhooks_on_updated_at_for_cleanup; DROP INDEX IF EXISTS public.index_webhooks_on_organization_id; @@ -839,6 +845,7 @@ DROP INDEX IF EXISTS public.idx_billing_on_enriched_events; DROP INDEX IF EXISTS public.idx_lookup_on_enriched_events; DROP INDEX IF EXISTS public.idx_unique_on_enriched_events; DROP INDEX IF EXISTS public.index_enriched_events_on_event_id; +ALTER TABLE IF EXISTS ONLY public.white_label_agreements DROP CONSTRAINT IF EXISTS white_label_agreements_pkey; ALTER TABLE IF EXISTS ONLY public.webhooks DROP CONSTRAINT IF EXISTS webhooks_pkey; ALTER TABLE IF EXISTS ONLY public.webhook_endpoints DROP CONSTRAINT IF EXISTS webhook_endpoints_pkey; ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS wallets_pkey; @@ -964,6 +971,7 @@ ALTER TABLE IF EXISTS ONLY public.active_storage_attachments DROP CONSTRAINT IF ALTER TABLE IF EXISTS public.versions ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS public.usage_monitoring_subscription_activities ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS public.quote_owners ALTER COLUMN id DROP DEFAULT; +DROP TABLE IF EXISTS public.white_label_agreements; DROP TABLE IF EXISTS public.webhooks; DROP TABLE IF EXISTS public.webhook_endpoints; DROP TABLE IF EXISTS public.wallets_invoice_custom_sections; @@ -3192,7 +3200,8 @@ CREATE TABLE public.plans ( deleted_at timestamp(6) without time zone, pending_deletion boolean DEFAULT false NOT NULL, invoice_display_name character varying, - bill_fixed_charges_monthly boolean DEFAULT false + bill_fixed_charges_monthly boolean DEFAULT false, + self_serve boolean DEFAULT true NOT NULL ); @@ -5176,6 +5185,34 @@ CREATE TABLE public.webhooks ( ); +-- +-- Name: white_label_agreements; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.white_label_agreements ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + customer_id uuid NOT NULL, + plan_code character varying NOT NULL, + msa_version character varying NOT NULL, + terms_version character varying NOT NULL, + status character varying DEFAULT 'pending'::character varying NOT NULL, + subscription_external_id character varying, + accepted_at timestamp(6) without time zone, + accepted_by_name character varying, + accepted_by_title character varying, + accepted_by_email character varying, + accepted_ip character varying, + accepted_user_agent character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + reset_count integer DEFAULT 0 NOT NULL, + last_reset_at timestamp(6) without time zone, + last_reset_reason character varying, + token_nonce character varying +); + + -- -- Name: enriched_events_default; Type: TABLE ATTACH; Schema: public; Owner: - -- @@ -6180,6 +6217,14 @@ ALTER TABLE ONLY public.webhooks ADD CONSTRAINT webhooks_pkey PRIMARY KEY (id); +-- +-- Name: white_label_agreements white_label_agreements_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.white_label_agreements + ADD CONSTRAINT white_label_agreements_pkey PRIMARY KEY (id); + + -- -- Name: index_enriched_events_on_event_id; Type: INDEX; Schema: public; Owner: - -- @@ -9761,6 +9806,34 @@ CREATE INDEX index_webhooks_on_updated_at_for_cleanup ON public.webhooks USING b CREATE INDEX index_webhooks_on_webhook_endpoint_id ON public.webhooks USING btree (webhook_endpoint_id); +-- +-- Name: index_white_label_agreements_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_white_label_agreements_on_customer_id ON public.white_label_agreements USING btree (customer_id); + + +-- +-- Name: index_white_label_agreements_on_customer_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_white_label_agreements_on_customer_unique ON public.white_label_agreements USING btree (customer_id) WHERE ((status)::text <> 'superseded'::text); + + +-- +-- Name: index_white_label_agreements_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_white_label_agreements_on_organization_id ON public.white_label_agreements USING btree (organization_id); + + +-- +-- Name: index_white_label_agreements_on_organization_id_and_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_white_label_agreements_on_organization_id_and_status ON public.white_label_agreements USING btree (organization_id, status); + + -- -- Name: index_wt_invoice_custom_sections_unique; Type: INDEX; Schema: public; Owner: - -- @@ -11265,6 +11338,14 @@ ALTER TABLE ONLY public.add_ons_taxes ADD CONSTRAINT fk_rails_89e1020aca FOREIGN KEY (tax_id) REFERENCES public.taxes(id); +-- +-- Name: white_label_agreements fk_rails_8a1c5737b0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.white_label_agreements + ADD CONSTRAINT fk_rails_8a1c5737b0 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + -- -- Name: invoice_metadata fk_rails_8bb5b094c4; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -11593,6 +11674,14 @@ ALTER TABLE ONLY public.entitlement_entitlements ADD CONSTRAINT fk_rails_b61aa73940 FOREIGN KEY (entitlement_feature_id) REFERENCES public.entitlement_features(id); +-- +-- Name: white_label_agreements fk_rails_b659c0957e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.white_label_agreements + ADD CONSTRAINT fk_rails_b659c0957e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + -- -- Name: subscription_activation_rules fk_rails_b749d2045d; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -12224,7 +12313,12 @@ ALTER TABLE ONLY public.membership_roles SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20261017000001'), +('20261017000000'), ('20260728000000'), +('20260701000000'), +('20260622000000'), +('20260616000000'), ('20260504134804'), ('20260430102814'), ('20260430102813'), @@ -13220,3 +13314,4 @@ INSERT INTO "schema_migrations" (version) VALUES ('20220530091046'), ('20220526101535'), ('20220525122759'); + From 17ec5548c3fd81a2dbc0780110dc37e4c88cefdf Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Sat, 17 Oct 2026 11:37:21 -0400 Subject: [PATCH 3/4] Update GraphQL schema dump --- schema.graphql | 123 +++++++++++++++ schema.json | 415 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 537 insertions(+), 1 deletion(-) diff --git a/schema.graphql b/schema.graphql index 7882f79de02a..1b8e087a3e0a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -453,11 +453,14 @@ type AddOnCollection { Stripe input arguments """ input AddStripePaymentProviderInput { + billingEntityCode: String + """ A unique identifier for the client performing the mutation. """ clientMutationId: String code: String! + connectedAccountId: String name: String! secretKey: String successRedirectUrl: String @@ -1015,6 +1018,21 @@ type CashfreeProvider { successRedirectUrl: String } +""" +Autogenerated input type of ChangeCustomerPortalSubscriptionPlan +""" +input ChangeCustomerPortalSubscriptionPlanInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Target plan code; product key (prefix before '-') determines which sub to switch + """ + planCode: String! +} + type Charge { appliedPricingUnit: AppliedPricingUnit billableMetric: BillableMetric! @@ -2865,6 +2883,21 @@ input CreateCustomerInput { zipcode: String } +""" +Autogenerated input type of CreateCustomerPortalSubscription +""" +input CreateCustomerPortalSubscriptionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Plan code to subscribe to (must be visible to this customer's organization) + """ + planCode: String! +} + """ Autogenerated input type of CreateCustomerPortalWalletTransaction """ @@ -3173,6 +3206,17 @@ input CreateOrUpdateSubscriptionEntitlementInput { subscriptionId: ID! } +""" +Autogenerated input type of CreateOrganization +""" +input CreateOrganizationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + name: String! +} + """ Autogenerated input type of CreatePasswordReset """ @@ -3234,6 +3278,7 @@ input CreatePlanInput { minimumCommitment: CommitmentInput name: String! payInAdvance: Boolean! + selfServe: Boolean taxCodes: [String!] trialPeriod: Float usageThresholds: [UsageThresholdInput!] @@ -4528,6 +4573,7 @@ type CustomerPortalCustomer { accountType: CustomerAccountTypeEnum! addressLine1: String addressLine2: String + agreementSigningUrl: String applicableTimezone: TimezoneEnum! billingConfiguration: CustomerBillingConfiguration billingEntityBillingConfiguration: BillingEntityBillingConfiguration! @@ -4542,6 +4588,7 @@ type CustomerPortalCustomer { lastname: String legalName: String legalNumber: String + mustSignAgreement: Boolean! name: String premium: Boolean! shippingAddress: CustomerAddress @@ -7193,6 +7240,16 @@ type Mutation { input: BillingEntityUpdateAppliedDunningCampaignInput! ): BillingEntity + """ + Switch the customer-portal user's active subscription within a product to a different plan + """ + changeCustomerPortalSubscriptionPlan( + """ + Parameters for ChangeCustomerPortalSubscriptionPlan + """ + input: ChangeCustomerPortalSubscriptionPlanInput! + ): Subscription + """ Clone a quote version """ @@ -7353,6 +7410,16 @@ type Mutation { input: CreateCustomerInput! ): Customer + """ + Add a new product subscription for the customer-portal user + """ + createCustomerPortalSubscription( + """ + Parameters for CreateCustomerPortalSubscription + """ + input: CreateCustomerPortalSubscriptionInput! + ): Subscription + """ Creates a new Customer Wallet Transaction from Customer Portal """ @@ -7523,6 +7590,16 @@ type Mutation { input: CreateOrUpdateSubscriptionEntitlementInput! ): SubscriptionEntitlement + """ + Creates a new organization and adds the current user as an admin member + """ + createOrganization( + """ + Parameters for CreateOrganization + """ + input: CreateOrganizationInput! + ): CurrentOrganization + """ Creates a new password reset """ @@ -8395,6 +8472,16 @@ type Mutation { input: TerminateCouponInput! ): Coupon + """ + Cancel the active subscription for a given product + """ + terminateCustomerPortalSubscription( + """ + Parameters for TerminateCustomerPortalSubscription + """ + input: TerminateCustomerPortalSubscriptionInput! + ): Subscription + """ Terminates a new Customer Wallet """ @@ -9514,6 +9601,11 @@ type Plan { organization: Organization parent: Plan payInAdvance: Boolean! + + """ + Whether the plan is offered in the self-serve customer portal + """ + selfServe: Boolean! subscriptionsCount: Int! taxes: [Tax!] trialPeriod: Float @@ -10063,6 +10155,21 @@ type Query { """ customerInvoices(customerId: ID!, limit: Int, page: Int, searchTerm: String, status: [InvoiceStatusTypeEnum!]): InvoiceCollection! + """ + Lists plans available to the customer portal user, filtered by product + """ + customerPortalAvailablePlans( + """ + Omit plans the customer already has an active subscription to + """ + excludeCurrent: Boolean = false + + """ + Filter to plans whose code starts with `${product_key}-` + """ + productKey: String + ): [Plan!]! + """ Query the projected usage of the customer on the current billing period """ @@ -11692,6 +11799,21 @@ input TerminateCouponInput { id: ID! } +""" +Autogenerated input type of TerminateCustomerPortalSubscription +""" +input TerminateCustomerPortalSubscriptionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Product to cancel (e.g. `aistack`, `growth`, `memory`) + """ + productKey: String! +} + """ Autogenerated input type of TerminateCustomerWallet """ @@ -13042,6 +13164,7 @@ input UpdatePlanInput { minimumCommitment: CommitmentInput name: String! payInAdvance: Boolean! + selfServe: Boolean taxCodes: [String!] trialPeriod: Float usageThresholds: [UsageThresholdInput!] diff --git a/schema.json b/schema.json index 21d93014a50e..0f29d8c5ed49 100644 --- a/schema.json +++ b/schema.json @@ -1659,6 +1659,30 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectedAccountId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "clientMutationId", "description": "A unique identifier for the client performing the mutation.", @@ -5887,6 +5911,45 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "ChangeCustomerPortalSubscriptionPlanInput", + "description": "Autogenerated input type of ChangeCustomerPortalSubscriptionPlan", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": "Target plan code; product key (prefix before '-') determines which sub to switch", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Charge", @@ -12199,6 +12262,45 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerPortalSubscriptionInput", + "description": "Autogenerated input type of CreateCustomerPortalSubscription", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": "Plan code to subscribe to (must be visible to this customer's organization)", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "CreateCustomerPortalWalletTransactionInput", @@ -14266,6 +14368,45 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "CreateOrganizationInput", + "description": "Autogenerated input type of CreateOrganization", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "CreatePasswordResetInput", @@ -14601,6 +14742,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "selfServe", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "taxCodes", "description": null, @@ -20695,6 +20848,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "agreementSigningUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "applicableTimezone", "description": null, @@ -20879,6 +21044,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "mustSignAgreement", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "name", "description": null, @@ -37338,6 +37519,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "changeCustomerPortalSubscriptionPlan", + "description": "Switch the customer-portal user's active subscription within a product to a different plan", + "args": [ + { + "name": "input", + "description": "Parameters for ChangeCustomerPortalSubscriptionPlan", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChangeCustomerPortalSubscriptionPlanInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "cloneQuoteVersion", "description": "Clone a quote version", @@ -37802,6 +38012,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "createCustomerPortalSubscription", + "description": "Add a new product subscription for the customer-portal user", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCustomerPortalSubscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerPortalSubscriptionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "createCustomerPortalWalletTransaction", "description": "Creates a new Customer Wallet Transaction from Customer Portal", @@ -38295,6 +38534,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "createOrganization", + "description": "Creates a new organization and adds the current user as an admin member", + "args": [ + { + "name": "input", + "description": "Parameters for CreateOrganization", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateOrganizationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CurrentOrganization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "createPasswordReset", "description": "Creates a new password reset", @@ -40855,6 +41123,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "terminateCustomerPortalSubscription", + "description": "Cancel the active subscription for a given product", + "args": [ + { + "name": "input", + "description": "Parameters for TerminateCustomerPortalSubscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TerminateCustomerPortalSubscriptionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "terminateCustomerWallet", "description": "Terminates a new Customer Wallet", @@ -47687,6 +47984,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "selfServe", + "description": "Whether the plan is offered in the self-serve customer portal", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "subscriptionsCount", "description": null, @@ -52148,6 +52461,55 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "customerPortalAvailablePlans", + "description": "Lists plans available to the customer portal user, filtered by product", + "args": [ + { + "name": "excludeCurrent", + "description": "Omit plans the customer already has an active subscription to", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "productKey", + "description": "Filter to plans whose code starts with `${product_key}-`", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "customerPortalCustomerProjectedUsage", "description": "Query the projected usage of the customer on the current billing period", @@ -63300,6 +63662,45 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "TerminateCustomerPortalSubscriptionInput", + "description": "Autogenerated input type of TerminateCustomerPortalSubscription", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "productKey", + "description": "Product to cancel (e.g. `aistack`, `growth`, `memory`)", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "TerminateCustomerWalletInput", @@ -68820,6 +69221,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "selfServe", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "taxCodes", "description": null, @@ -74877,4 +75290,4 @@ ] } } -} \ No newline at end of file +} From db06f6f5e17e8ebd0f1c5de11688657b66c88255 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Sat, 17 Oct 2026 11:37:21 -0400 Subject: [PATCH 4/4] Apply default test resolver outside example hooks Records created in around hooks were resolved before the before-hook stub was installed. --- spec/support/outbound_url_guard_helper.rb | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/spec/support/outbound_url_guard_helper.rb b/spec/support/outbound_url_guard_helper.rb index 4d898668fbb2..6d6387a9589d 100644 --- a/spec/support/outbound_url_guard_helper.rb +++ b/spec/support/outbound_url_guard_helper.rb @@ -1,10 +1,19 @@ # frozen_string_literal: true # The test suite has no DNS access and factories use random hostnames. -# Resolve every hostname to a fixed public address by default; -# specs exercising the guard override `resolve_host` explicitly. +# Resolve every hostname to a fixed public address by default. This is done +# at load time rather than only in a `before` hook so that records created in +# `around` hooks or `before(:all)` blocks are covered as well. +LagoHttpClient::OutboundUrlGuard.singleton_class.class_eval do + def resolve_host(_host) + ["93.184.215.14"] + end +end + +# Spy on the resolver so specs can assert whether it was called; specs +# exercising the guard override `resolve_host` explicitly. RSpec.configure do |config| config.before do - allow(LagoHttpClient::OutboundUrlGuard).to receive(:resolve_host).and_return(["93.184.215.14"]) + allow(LagoHttpClient::OutboundUrlGuard).to receive(:resolve_host).and_call_original end end