Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5640df7
Add allowed_sources support for mTLS app-to-app routing
rkoster Mar 5, 2026
223902c
Add unit tests for allowed_sources validation
rkoster Mar 5, 2026
f560db5
Fix allowed_sources validation to handle symbol keys
rkoster Mar 5, 2026
936d4dd
Rename allowed_sources to mtls_allowed_sources for clarity
rkoster Mar 5, 2026
9747046
Refactor mTLS route options to RFC-0027 compliant flat format
rkoster Mar 5, 2026
df1ac2f
Implement RFC domain-scoped mTLS routing with /v3/access_rules API
rkoster Apr 9, 2026
67f7862
Fix access_rules_controller permissions query
rkoster Apr 9, 2026
568bb0b
Add automatic Diego sync callbacks to RouteAccessRule
rkoster Apr 9, 2026
ea99dbb
Implement include=selector_resource for /v3/access_rules endpoint
rkoster Apr 10, 2026
8903be9
Add space_guids filtering to /v3/access_rules endpoint
rkoster Apr 10, 2026
e641586
Implement include=route for /v3/access_rules endpoint
rkoster Apr 10, 2026
6381ed8
Remove name field from access rules, add read-only relationships per …
rkoster Apr 15, 2026
eaf45da
Add metadata support to RouteAccessRule model
rkoster Apr 15, 2026
9afa57a
Fix class loading for RouteAccessRule metadata models
rkoster Apr 15, 2026
3791874
Add validation to prevent access rules on internal domains per RFC
rkoster Apr 15, 2026
abdcfda
Consolidate access rules migrations, fix RuboCop offenses, and clean …
rkoster Apr 15, 2026
9a18941
Fix race condition, double join, LIKE injection, N+1 queries, and dom…
rkoster Apr 15, 2026
79f0da9
Fix incomplete LIKE metacharacter escaping (CodeQL rb/incomplete-sani…
rkoster Apr 15, 2026
a5ab35b
Add tests for LIKE metacharacter escaping (backslash, underscore)
rkoster Apr 15, 2026
63f1f5d
Fix MySQL key length limit in metadata table migration
rkoster Apr 15, 2026
3f6939c
Fix routing_info_spec: remove nonexistent name field from RouteAccess…
rkoster Apr 15, 2026
1a37cf6
Fix route presenter regression: include options: {} when empty
rkoster Apr 15, 2026
18e01f4
Fix CI failures: route presenter options logic and domain V2 serializ…
rkoster Apr 15, 2026
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
66 changes: 66 additions & 0 deletions app/access/access_rule_access.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
module VCAP::CloudController
class AccessRuleAccess < BaseAccess
# Space Developer of the route's space can manage access rules.
# No bilateral requirement — destination-controlled auth only.

def create?(access_rule, _params=nil)
return true if admin_user?

route = access_rule.route
return false unless route

space = route.space
context.user_email && context.user.is_a?(User) &&
space.developers.include?(context.user)
end

def read?(access_rule)
return true if admin_user? || admin_read_only_user? || global_auditor?

route = access_rule.route
return false unless route

object_is_visible_to_user?(access_rule, context.user)
end

def update?(access_rule, _params=nil)
create?(access_rule)
end

def delete?(access_rule)
create?(access_rule)
end

def index?(_object_class, _params=nil)
admin_user? || admin_read_only_user? || has_read_scope? || global_auditor?
end

def read_with_token?(_)
admin_user? || admin_read_only_user? || has_read_scope? || global_auditor?
end

def create_with_token?(_)
admin_user? || has_write_scope?
end

def read_for_update_with_token?(_)
admin_user? || has_write_scope?
end

def can_remove_related_object_with_token?(*)
read_for_update_with_token?(*)
end

def read_related_object_for_update_with_token?(*)
read_for_update_with_token?(*)
end

def update_with_token?(_)
admin_user? || has_write_scope?
end

def delete_with_token?(_)
admin_user? || has_write_scope?
end
end
end
2 changes: 2 additions & 0 deletions app/actions/domain_create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def create(message:, shared_organizations: [])
end

domain.router_group_guid = message.router_group_guid
domain.enforce_access_rules = message.enforce_access_rules || false
domain.access_rules_scope = message.access_rules_scope

Domain.db.transaction do
domain.save
Expand Down
166 changes: 166 additions & 0 deletions app/controllers/v3/access_rules_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
require 'messages/access_rule_create_message'
require 'messages/access_rule_update_message'
require 'messages/access_rules_list_message'
require 'presenters/v3/access_rule_presenter'
require 'decorators/include_access_rule_selector_resource_decorator'
require 'decorators/include_access_rule_route_decorator'

class AccessRulesController < ApplicationController
def index
message = AccessRulesListMessage.from_params(query_params)
invalid_param!(message.errors.full_messages) unless message.valid?

dataset = build_dataset(message)

decorators = []
decorators << IncludeAccessRuleSelectorResourceDecorator if IncludeAccessRuleSelectorResourceDecorator.match?(message.include)
decorators << IncludeAccessRuleRouteDecorator if IncludeAccessRuleRouteDecorator.match?(message.include)

render status: :ok, json: Presenters::V3::PaginatedListPresenter.new(
presenter: Presenters::V3::AccessRulePresenter,
paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)),
path: '/v3/access_rules',
message: message,
decorators: decorators
)
end

def show
access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid])
resource_not_found!(:access_rule) unless access_rule

route = access_rule.route
resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)

render status: :ok, json: Presenters::V3::AccessRulePresenter.new(access_rule)
end

def create
message = AccessRuleCreateMessage.new(hashed_params[:body])
unprocessable!(message.errors.full_messages) unless message.valid?

route = find_and_authorize_route(message.route_guid)
validate_route_domain(route)

access_rule = VCAP::CloudController::RouteAccessRule.db.transaction do
# Lock existing access rules for this route to prevent concurrent inserts
# from violating cf:any exclusivity or uniqueness constraints
VCAP::CloudController::RouteAccessRule.where(route_id: route.id).for_update.all

validate_selector_exclusivity(route, message.selector)

rule = VCAP::CloudController::RouteAccessRule.new(
guid: SecureRandom.uuid,
selector: message.selector,
route_id: route.id,
created_at: Time.now.utc,
updated_at: Time.now.utc
)
rule.save
rule
end

render status: :created, json: Presenters::V3::AccessRulePresenter.new(access_rule)
rescue Sequel::UniqueConstraintViolation
unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.")
end

def update
access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid])
resource_not_found!(:access_rule) unless access_rule

route = access_rule.route
resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
suspended! unless permission_queryer.is_space_active?(route.space.id)

message = AccessRuleUpdateMessage.new(hashed_params[:body])
unprocessable!(message.errors.full_messages) unless message.valid?

VCAP::CloudController::MetadataUpdate.update(access_rule, message)

render status: :ok, json: Presenters::V3::AccessRulePresenter.new(access_rule.reload)
end

def destroy
access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid])
resource_not_found!(:access_rule) unless access_rule

route = access_rule.route
resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
suspended! unless permission_queryer.is_space_active?(route.space.id)

access_rule.destroy
head :no_content
end

private

def find_and_authorize_route(route_guid)
route = VCAP::CloudController::Route.find(guid: route_guid)
resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
suspended! unless permission_queryer.is_space_active?(route.space.id)
route
end

def validate_route_domain(route)
if route.domain.internal?
unprocessable!('Cannot create access rules for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.')
end
unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") unless route.domain.enforce_access_rules
end

def validate_selector_exclusivity(route, selector)
existing_selectors = route.access_rules.map(&:selector)

# Enforce cf:any exclusivity: if route already has a cf:any rule, reject new rules;
# if new rule is cf:any, reject if route already has any rules.
unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.") if selector == 'cf:any' && existing_selectors.any?
unprocessable!("Cannot add selector '#{selector}': route already has a 'cf:any' rule.") if existing_selectors.include?('cf:any') && selector != 'cf:any'

# Uniqueness: selector must be unique per route
unprocessable!("An access rule with selector '#{selector}' already exists for this route.") if existing_selectors.include?(selector)
end

def build_dataset(message)
dataset = VCAP::CloudController::RouteAccessRule.dataset

if permission_queryer.can_read_globally?
readable_route_ids = VCAP::CloudController::Route.select(:id)
else
readable_space_ids = permission_queryer.readable_space_scoped_spaces_query.select(:id)
readable_route_ids = VCAP::CloudController::Route.where(space_id: readable_space_ids).select(:id)
end

dataset = dataset.where(route_id: readable_route_ids)

# Join routes at most once when either route_guids or space_guids is requested
if message.requested?(:route_guids) || message.requested?(:space_guids)
dataset = dataset.
join(:routes, id: :route_id).
select_all(:route_access_rules)

dataset = dataset.where(Sequel[:routes][:guid] => message.route_guids) if message.requested?(:route_guids)

dataset = dataset.where(Sequel[:routes][:space_id] => VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)) if message.requested?(:space_guids)
end

dataset = dataset.where(guid: message.guids) if message.requested?(:guids)
dataset = dataset.where(selector: message.selectors) if message.requested?(:selectors)

if message.requested?(:selector_resource_guids)
# Text-match against selector string for resource GUIDs
# Handles cf:app:<guid>, cf:space:<guid>, cf:org:<guid>
# Escape LIKE metacharacters (\, %, _) in user-provided values
conditions = message.selector_resource_guids.map do |guid|
escaped_guid = guid.gsub('\\', '\\\\').gsub('%', '\\%').gsub('_', '\\_')
Sequel.like(:selector, "%#{escaped_guid}%")
end
dataset = dataset.where(Sequel.|(*conditions))
end

dataset
end
end
27 changes: 27 additions & 0 deletions app/decorators/include_access_rule_route_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module VCAP::CloudController
class IncludeAccessRuleRouteDecorator
# Handles `?include=route` for GET /v3/access_rules
# Includes the route resources associated with the access rules

def self.match?(include_params)
include_params&.include?('route')
end

def self.decorate(hash, access_rules)
hash[:included] ||= {}

# Collect all unique route IDs from access rules
route_ids = access_rules.map(&:route_id).uniq

# Fetch routes with their associations
routes = Route.where(id: route_ids).
order(:created_at, :guid).
eager(Presenters::V3::RoutePresenter.associated_resources).all

# Present routes
hash[:included][:routes] = routes.map { |route| Presenters::V3::RoutePresenter.new(route).to_hash }

hash
end
end
end
75 changes: 75 additions & 0 deletions app/decorators/include_access_rule_selector_resource_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
module VCAP::CloudController
class IncludeAccessRuleSelectorResourceDecorator
# Handles `?include=selector_resource` for GET /v3/access_rules
# Stale/missing resources (selector GUIDs that no longer exist) are silently absent.

SELECTOR_REGEX = /\Acf:(app|space|org):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\z/

def self.match?(include_params)
return false unless include_params

# Match if any of: selector_resource, app, space, organization
include_params.intersect?(%w[selector_resource app space organization])
end

def self.decorate(hash, access_rules)
hash[:included] ||= {}

# Collect all GUIDs by type
app_guids = []
space_guids = []
org_guids = []

access_rules.each do |rule|
match = SELECTOR_REGEX.match(rule.selector)
next unless match

resource_type = match[1]
resource_guid = match[2]

case resource_type
when 'app'
app_guids << resource_guid
when 'space'
space_guids << resource_guid
when 'org'
org_guids << resource_guid
end
end

# Fetch and present resources
hash[:included][:apps] = fetch_and_present_apps(app_guids.uniq)
hash[:included][:spaces] = fetch_and_present_spaces(space_guids.uniq)
hash[:included][:organizations] = fetch_and_present_organizations(org_guids.uniq)

hash
end

private_class_method def self.fetch_and_present_apps(guids)
return [] if guids.empty?

apps = AppModel.where(guid: guids).
order(:created_at, :guid).
eager(Presenters::V3::AppPresenter.associated_resources).all
apps.map { |app| Presenters::V3::AppPresenter.new(app).to_hash }
end

private_class_method def self.fetch_and_present_spaces(guids)
return [] if guids.empty?

spaces = Space.where(guid: guids).
order(:created_at, :guid).
eager(Presenters::V3::SpacePresenter.associated_resources).all
spaces.map { |space| Presenters::V3::SpacePresenter.new(space).to_hash }
end

private_class_method def self.fetch_and_present_organizations(guids)
return [] if guids.empty?

orgs = Organization.where(guid: guids).
order(:created_at, :guid).
eager(Presenters::V3::OrganizationPresenter.associated_resources).all
orgs.map { |org| Presenters::V3::OrganizationPresenter.new(org).to_hash }
end
end
end
50 changes: 50 additions & 0 deletions app/messages/access_rule_create_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require 'messages/metadata_base_message'

module VCAP::CloudController
class AccessRuleCreateMessage < MetadataBaseMessage
SELECTOR_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/

register_allowed_keys %i[
selector
relationships
]

validates_with NoAdditionalKeysValidator
validates_with RelationshipValidator

validates :selector, presence: true, string: true

validate :selector_format_valid
validate :selector_not_cf_any_with_others

delegate :route_guid, to: :relationships_message

def relationships_message
@relationships_message ||= Relationships.new(relationships&.deep_symbolize_keys)
end

private

def selector_format_valid
return unless selector.is_a?(String)
return if SELECTOR_REGEX.match?(selector)

errors.add(:selector, "must be in format 'cf:app:<uuid>', 'cf:space:<uuid>', 'cf:org:<uuid>', or 'cf:any'")
end

def selector_not_cf_any_with_others
# enforced at the controller level when checking existing rules on the route
end

class Relationships < BaseMessage
register_allowed_keys [:route]

validates_with NoAdditionalKeysValidator
validates :route, presence: true, to_one_relationship: true

def route_guid
HashUtils.dig(route, :data, :guid)
end
end
end
end
Loading
Loading