Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/admin/api/applications_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
class Admin::Api::ApplicationsController < Admin::Api::BaseController
include ApiAuthentication::ByZyncToken
representer ::Cinstance

paginate only: :index
Expand Down
1 change: 1 addition & 0 deletions app/controllers/admin/api/providers_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
class Admin::Api::ProvidersController < Admin::Api::BaseController
include ApiAuthentication::ByZyncToken

wrap_parameters Account

Expand Down
1 change: 1 addition & 0 deletions app/controllers/admin/api/services/proxies_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

class Admin::Api::Services::ProxiesController < Admin::Api::Services::BaseController
include ApiAuthentication::ByZyncToken

represents :json, entity: ::ProxyRepresenter::JSON
represents :xml, entity: ::ProxyRepresenter::XML
Expand Down
2 changes: 1 addition & 1 deletion app/lib/api_authentication/by_access_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def authenticated_token

token = domain_account.access_tokens.find_from_value(given_token)

return if token.blank? || token.expired?
return if token.blank? || token.expired? || token.name == AccessToken::OIDC_SYNC_TOKEN
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line invalidates all OIDC access tokens from now on, so it doesn't matter if they still sit plain text in the Zync DB.


@authenticated_token = token
end
Expand Down
44 changes: 44 additions & 0 deletions app/lib/api_authentication/by_zync_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module ApiAuthentication
module ByZyncToken
extend ActiveSupport::Concern

included do
prepend_before_action :authenticate_zync_request, only: %i[show find]
Copy link
Copy Markdown
Contributor Author

@jlledom jlledom May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zync authenticated actions, in particular, setting current_user to the impersonation admin or the first admin, are only enforced when @zync_authenticated == true, and that ivar can only be set to true in show and find actions.

end

private

def current_user
if @zync_authenticated
@current_user ||= User.current = (domain_account.find_impersonation_admin || domain_account.first_admin!)
else
super
end
end

def authenticate_zync_request
return unless zync_request?
return if domain_account.master?

@zync_authenticated = true
end

def zync_request?
AuthenticatedSystem::Request.new(request).zync?
end

# Force read-only DB transaction for Zync requests.
# ByAccessToken's version calls PermissionEnforcer.enforce(authenticated_token) — with
# no access token, authenticated_token is nil, level becomes nil, and
# requires_transaction? returns nil, skipping enforcement entirely.
def enforce_access_token_permission(&block)
if @zync_authenticated
ApiAuthentication::ByAccessToken::PermissionEnforcer.enforce(OpenStruct.new(permission: 'ro'), &block)
else
super
end
end
end
end
5 changes: 0 additions & 5 deletions app/models/access_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,8 @@ def self.find_from_id_or_value(id_or_value)
find_by(id: id_or_value) || find_from_value(id_or_value)
end

# This can't change or it will create new tokens for everyone
OIDC_SYNC_TOKEN = 'OIDC Synchronization Token'.freeze

def self.oidc_sync
create_with(scopes: %w[account_management], permission: 'ro').find_or_create_by!(name: OIDC_SYNC_TOKEN)
end

Comment on lines -148 to -151
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We stop creating new OIDC tokens

def scopes=(values)
super Array(values).select(&:present?)
end
Expand Down
6 changes: 2 additions & 4 deletions app/workers/zync_worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,8 @@ def provider_endpoint(provider)

delegate :provider_access_token, to: :class

def self.provider_access_token(provider)
user = provider.find_impersonation_admin || provider.first_admin!

user.access_tokens.oidc_sync.value
def self.provider_access_token(_provider)
'zync'
Comment on lines +175 to +176
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super fan of this, but the PUT /tenant endpoint at Zync requires this parameter, we must send anything, so we send this dumb value.

end

def notification_url
Expand Down
162 changes: 162 additions & 0 deletions test/integration/api_authentication/by_zync_token_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# frozen_string_literal: true

require 'test_helper'

# Integration tests for ApiAuthentication::ByZyncToken.
#
# Uses a fake controller with fake routes (pattern from forbid_params_test.rb) to
# avoid coupling to real Admin API controllers and their additional before_actions.
module ApiAuthentication
module ByZyncTokenIntegration
# Minimal base that mirrors what real Admin API controllers set up:
# ApplicationController -> ByAccessToken -> ByZyncToken.
class FakeController < Admin::Api::BaseController
include ApiAuthentication::ByZyncToken

def show
render json: { account_id: current_account.id, user_id: current_user.id }
end

def update
render plain: 'ok'
end
end

# Controller whose show action attempts a DB write — used to prove the
# read-only transaction blocks it.
class WriteAttemptController < FakeController
def show
User.where(id: -1).update_all(username: 'hacked')
render plain: 'ok'
rescue ActiveRecord::StatementInvalid => e
render plain: e.message, status: :forbidden
end
end

module TestHelpers
ZYNC_TOKEN = 'test-zync-token'

def with_test_routes
Rails.application.routes.draw do
get '/zync_test/show' => 'api_authentication/by_zync_token_integration/fake#show'
put '/zync_test/update' => 'api_authentication/by_zync_token_integration/fake#update'
get '/zync_test/write_attempt' => 'api_authentication/by_zync_token_integration/write_attempt#show'
end
yield
ensure
Rails.application.routes_reloader.reload!
end

def zync_headers(token = ZYNC_TOKEN)
{ 'X-Zync-Token' => token }
end
end
end

class ByZyncTokenIntegrationTest < ActionDispatch::IntegrationTest
include ByZyncTokenIntegration::TestHelpers

def setup
ThreeScale.config.stubs(:zync_authentication_token).returns(ZYNC_TOKEN)
@provider = FactoryBot.create(:provider_account)
host! @provider.external_admin_domain
end
end

class AccessTokenTest < ByZyncTokenIntegrationTest
test 'rejects GET with no auth at all' do
with_test_routes do
get '/zync_test/show'
assert_response :forbidden
end
end

test 'regular access token auth still works on Zync-capable endpoints' do
user = FactoryBot.create(:member, account: @provider, admin_sections: %w[partners])
token = FactoryBot.create(:access_token, owner: user, scopes: 'account_management', permission: 'rw')
with_test_routes do
get '/zync_test/show', params: { access_token: token.plaintext_value }
assert_response :success
end
end

test 'oidc_sync tokens are rejected even on Zync-capable endpoints' do
user = FactoryBot.create(:member, account: @provider, admin_sections: %w[partners])
token = FactoryBot.create(:access_token, owner: user, scopes: 'account_management',
name: AccessToken::OIDC_SYNC_TOKEN)
with_test_routes do
get '/zync_test/show', params: { access_token: token.plaintext_value }
assert_response :forbidden
end
end
end

class ZyncTokenTest < ByZyncTokenIntegrationTest
disable_transactional_fixtures!

test 'rejects GET with an invalid X-Zync-Token' do
with_test_routes do
get '/zync_test/show', headers: zync_headers('wrong')
assert_response :forbidden
end
end

test 'rejects requests targeting the master domain even with a valid X-Zync-Token' do
host! master_account.internal_admin_domain
with_test_routes do
get '/zync_test/show', headers: zync_headers
assert_response :forbidden
end
end

test 'does not authenticate write actions via X-Zync-Token' do
with_test_routes do
put '/zync_test/update', headers: zync_headers
assert_response :forbidden
end
end

test 'authenticates GET requests with a valid X-Zync-Token' do
with_test_routes do
get '/zync_test/show', headers: zync_headers
assert_response :success
end
end

test 'Zync-authenticated requests enforce a read-only DB transaction' do
with_test_routes do
get '/zync_test/write_attempt', headers: zync_headers
assert_response :forbidden
assert_match(/read.only transaction/i, response.body)
end
end
end

class DomainRoutingTest < ByZyncTokenIntegrationTest
disable_transactional_fixtures!

test 'authenticates as the admin of the provider whose domain is in the Host header' do
with_test_routes do
get '/zync_test/show', headers: zync_headers
assert_response :success
assert_equal @provider.id, response.parsed_body['account_id']
end
end

test 'X-Forwarded-Host overrides Host header for domain resolution' do
provider_b = FactoryBot.create(:provider_account)
with_test_routes do
get '/zync_test/show', headers: zync_headers.merge('X-Forwarded-Host' => provider_b.internal_admin_domain)
assert_response :success
assert_equal provider_b.id, response.parsed_body['account_id']
end
end

test 'rejects master domain via X-Forwarded-Host even with valid Zync token' do
with_test_routes do
get '/zync_test/show', headers: zync_headers.merge('X-Forwarded-Host' => master_account.internal_admin_domain)
assert_response :forbidden
end
end
end
end
11 changes: 11 additions & 0 deletions test/integration/by_access_token_integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,15 @@ def test_index_with_access_token

assert_response :forbidden
end

test 'oidc_sync tokens are rejected even with a valid plaintext value' do
# Create a token with the oidc_sync name and store its plaintext value
token = FactoryBot.create(:access_token, owner: @user, scopes: 'account_management',
name: AccessToken::OIDC_SYNC_TOKEN)
plaintext = token.plaintext_value

get admin_api_accounts_path(format: :xml), params: { access_token: plaintext }

assert_response :forbidden
end
end
8 changes: 8 additions & 0 deletions test/models/access_token_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,14 @@ def test_find_from_value_rejects_leaked_hash_as_token
assert access_token.valid?
end

test 'AccessToken.oidc_sync no longer exists' do
assert_not AccessToken.respond_to?(:oidc_sync)
end

test 'OIDC_SYNC_TOKEN constant is still defined for rejection logic' do
assert_equal 'OIDC Synchronization Token', AccessToken::OIDC_SYNC_TOKEN
end

private

def assert_access_token_audit_all_data(access_token, audit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def setup

def mock_token(attributes = {})
@params = { access_token: 'some-token' }
token = mock('access-token', attributes.merge(expired?: false))
token = mock('access-token', **attributes, expired?: false, name: 'access-token')
@access_tokens.expects(:find_from_value).with('some-token').returns(token)
token
end
Expand Down
Loading