diff --git a/app/access/route_policy_access.rb b/app/access/route_policy_access.rb new file mode 100644 index 00000000000..229e8fdd826 --- /dev/null +++ b/app/access/route_policy_access.rb @@ -0,0 +1,66 @@ +module VCAP::CloudController + class RoutePolicyAccess < BaseAccess + # Space Developer of the route's space can manage route policies. + # No bilateral requirement — destination-controlled auth only. + + def create?(route_policy, _params=nil) + return true if admin_user? + + route = route_policy.route + return false unless route + + space = route.space + context.user_email && context.user.is_a?(User) && + space.developers.include?(context.user) + end + + def read?(route_policy) + return true if admin_user? || admin_read_only_user? || global_auditor? + + route = route_policy.route + return false unless route + + object_is_visible_to_user?(route_policy, context.user) + end + + def update?(route_policy, _params=nil) + create?(route_policy) + end + + def delete?(route_policy) + create?(route_policy) + 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 diff --git a/app/actions/domain_create.rb b/app/actions/domain_create.rb index f69d05cd7a5..6f1016eb752 100644 --- a/app/actions/domain_create.rb +++ b/app/actions/domain_create.rb @@ -21,6 +21,8 @@ def create(message:, shared_organizations: []) end domain.router_group_guid = message.router_group_guid + domain.enforce_route_policies = message.enforce_route_policies || false + domain.route_policies_scope = message.route_policies_scope Domain.db.transaction do domain.save diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb new file mode 100644 index 00000000000..cda9352e5c2 --- /dev/null +++ b/app/controllers/v3/route_policies_controller.rb @@ -0,0 +1,168 @@ +require 'messages/route_policy_create_message' +require 'messages/route_policy_update_message' +require 'messages/route_policies_list_message' +require 'presenters/v3/route_policy_presenter' +require 'decorators/include_route_policy_source_decorator' +require 'decorators/include_route_policy_route_decorator' + +class RoutePoliciesController < ApplicationController + def index + message = RoutePoliciesListMessage.from_params(query_params) + invalid_param!(message.errors.full_messages) unless message.valid? + + dataset = build_dataset(message) + + decorators = [] + decorators << IncludeRoutePolicySourceDecorator if IncludeRoutePolicySourceDecorator.match?(message.include) + decorators << IncludeRoutePolicyRouteDecorator if IncludeRoutePolicyRouteDecorator.match?(message.include) + + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( + presenter: Presenters::V3::RoutePolicyPresenter, + paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)), + path: '/v3/route_policies', + message: message, + decorators: decorators + ) + end + + def show + route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) + resource_not_found!(:route_policy) unless route_policy + + route = route_policy.route + resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + + render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy) + end + + def create + message = RoutePolicyCreateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + route = find_and_authorize_route(message.route_guid) + validate_route_domain(route) + + route_policy = VCAP::CloudController::RoutePolicy.db.transaction do + # Lock existing route policies for this route to prevent concurrent inserts + # from violating cf:any exclusivity or uniqueness constraints + VCAP::CloudController::RoutePolicy.where(route_id: route.id).for_update.all + + validate_source_exclusivity(route, message.source) + + policy = VCAP::CloudController::RoutePolicy.new( + guid: SecureRandom.uuid, + source: message.source, + route_id: route.id, + created_at: Time.now.utc, + updated_at: Time.now.utc + ) + policy.save + policy + end + + render status: :created, json: Presenters::V3::RoutePolicyPresenter.new(route_policy) + rescue Sequel::UniqueConstraintViolation + unprocessable!("A route policy with source '#{message.source}' already exists for this route.") + end + + def update + route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) + resource_not_found!(:route_policy) unless route_policy + + route = route_policy.route + resource_not_found!(:route_policy) 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 = RoutePolicyUpdateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + VCAP::CloudController::MetadataUpdate.update(route_policy, message) + + render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy.reload) + end + + def destroy + route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) + resource_not_found!(:route_policy) unless route_policy + + route = route_policy.route + resource_not_found!(:route_policy) 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_policy.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 route policies for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.') + end + return if route.domain.enforce_route_policies + + unprocessable!("Cannot create route policies for route '#{route.guid}': the route's domain does not have enforce_route_policies enabled.") + end + + def validate_source_exclusivity(route, source) + existing_sources = route.route_policies.map(&:source) + + # Enforce cf:any exclusivity: if route already has a cf:any policy, reject new policies; + # if new policy is cf:any, reject if route already has any policies. + unprocessable!("Cannot add 'cf:any' source when other route policies already exist for this route.") if source == 'cf:any' && existing_sources.any? + unprocessable!("Cannot add source '#{source}': route already has a 'cf:any' policy.") if existing_sources.include?('cf:any') && source != 'cf:any' + + # Uniqueness: source must be unique per route + unprocessable!("A route policy with source '#{source}' already exists for this route.") if existing_sources.include?(source) + end + + def build_dataset(message) + dataset = VCAP::CloudController::RoutePolicy.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_policies) + + 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(source: message.sources) if message.requested?(:sources) + + if message.requested?(:source_guids) + # Text-match against source string for resource GUIDs + # Handles cf:app:, cf:space:, cf:org: + # Escape LIKE metacharacters (\, %, _) in user-provided values + conditions = message.source_guids.map do |guid| + escaped_guid = guid.gsub('\\', '\\\\').gsub('%', '\\%').gsub('_', '\\_') + Sequel.like(:source, "%#{escaped_guid}%") + end + dataset = dataset.where(Sequel.|(*conditions)) + end + + dataset + end +end diff --git a/app/decorators/include_route_policy_route_decorator.rb b/app/decorators/include_route_policy_route_decorator.rb new file mode 100644 index 00000000000..dbc4e1ea04f --- /dev/null +++ b/app/decorators/include_route_policy_route_decorator.rb @@ -0,0 +1,27 @@ +module VCAP::CloudController + class IncludeRoutePolicyRouteDecorator + # Handles `?include=route` for GET /v3/route_policies + # Includes the route resources associated with the route policies + + def self.match?(include_params) + include_params&.include?('route') + end + + def self.decorate(hash, route_policies) + hash[:included] ||= {} + + # Collect all unique route IDs from route policies + route_ids = route_policies.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 diff --git a/app/decorators/include_route_policy_source_decorator.rb b/app/decorators/include_route_policy_source_decorator.rb new file mode 100644 index 00000000000..271e769ac27 --- /dev/null +++ b/app/decorators/include_route_policy_source_decorator.rb @@ -0,0 +1,75 @@ +module VCAP::CloudController + class IncludeRoutePolicySourceDecorator + # Handles `?include=source` for GET /v3/route_policies + # Stale/missing resources (source GUIDs that no longer exist) are silently absent. + + SOURCE_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: source, app, space, organization + include_params.intersect?(%w[source app space organization]) + end + + def self.decorate(hash, route_policies) + hash[:included] ||= {} + + # Collect all GUIDs by type + app_guids = [] + space_guids = [] + org_guids = [] + + route_policies.each do |policy| + match = SOURCE_REGEX.match(policy.source) + 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 diff --git a/app/messages/domain_create_message.rb b/app/messages/domain_create_message.rb index 110bc0d499b..4456c13c1b8 100644 --- a/app/messages/domain_create_message.rb +++ b/app/messages/domain_create_message.rb @@ -16,6 +16,8 @@ class DomainCreateMessage < MetadataBaseMessage internal relationships router_group + enforce_route_policies + route_policies_scope ] def self.relationships_requested? @@ -59,6 +61,12 @@ def self.relationships_requested? allow_nil: true, boolean: true + validates :enforce_route_policies, + allow_nil: true, + boolean: true + + validate :route_policies_scope_validation + delegate :organization_guid, to: :relationships_message delegate :shared_organizations_guids, to: :relationships_message @@ -97,6 +105,17 @@ def router_group_validation errors.add(:router_group, 'guid must be a string') unless router_group_guid.is_a?(String) end + def route_policies_scope_validation + if requested?(:route_policies_scope) && !(route_policies_scope.nil? || %w[any org space].include?(route_policies_scope)) + errors.add(:route_policies_scope, "must be one of 'any', 'org', 'space'") + end + + return unless requested?(:enforce_route_policies) && enforce_route_policies == true + return unless !requested?(:route_policies_scope) || route_policies_scope.nil? + + errors.add(:route_policies_scope, 'is required when enforce_route_policies is true') + end + class Relationships < BaseMessage def self.shared_organizations_requested? @shared_organizations_requested ||= proc { |a| a.requested?(:shared_organizations) } diff --git a/app/messages/route_options_message.rb b/app/messages/route_options_message.rb index f79a2bb6a0c..7371b391558 100644 --- a/app/messages/route_options_message.rb +++ b/app/messages/route_options_message.rb @@ -2,7 +2,6 @@ module VCAP::CloudController class RouteOptionsMessage < BaseMessage - # Register all possible keys upfront so attr_accessors are created register_allowed_keys %i[loadbalancing hash_header hash_balance] def self.valid_route_options diff --git a/app/messages/route_policies_list_message.rb b/app/messages/route_policies_list_message.rb new file mode 100644 index 00000000000..bd3ec945aa1 --- /dev/null +++ b/app/messages/route_policies_list_message.rb @@ -0,0 +1,24 @@ +require 'messages/list_message' + +module VCAP::CloudController + class RoutePoliciesListMessage < ListMessage + register_allowed_keys %i[ + guids + route_guids + space_guids + sources + source_guids + include + ] + + validates_with NoAdditionalParamsValidator + validates_with IncludeParamValidator, valid_values: %w[source route app space organization] + + validates :space_guids, array: true, allow_nil: true + validates :source_guids, array: true, allow_nil: true + + def self.from_params(params) + super(params, %w[route_guids space_guids sources source_guids include]) + end + end +end diff --git a/app/messages/route_policy_create_message.rb b/app/messages/route_policy_create_message.rb new file mode 100644 index 00000000000..5ec09bd8914 --- /dev/null +++ b/app/messages/route_policy_create_message.rb @@ -0,0 +1,50 @@ +require 'messages/metadata_base_message' + +module VCAP::CloudController + class RoutePolicyCreateMessage < MetadataBaseMessage + SOURCE_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[ + source + relationships + ] + + validates_with NoAdditionalKeysValidator + validates_with RelationshipValidator + + validates :source, presence: true, string: true + + validate :source_format_valid + validate :source_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 source_format_valid + return unless source.is_a?(String) + return if SOURCE_REGEX.match?(source) + + errors.add(:source, "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'") + end + + def source_not_cf_any_with_others + # enforced at the controller level when checking existing policies 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 diff --git a/app/messages/route_policy_update_message.rb b/app/messages/route_policy_update_message.rb new file mode 100644 index 00000000000..998a59f2700 --- /dev/null +++ b/app/messages/route_policy_update_message.rb @@ -0,0 +1,9 @@ +require 'messages/metadata_base_message' + +module VCAP::CloudController + class RoutePolicyUpdateMessage < MetadataBaseMessage + register_allowed_keys [] + + validates_with NoAdditionalKeysValidator + end +end diff --git a/app/models.rb b/app/models.rb index 93e1594b38d..f85c9677dcc 100644 --- a/app/models.rb +++ b/app/models.rb @@ -69,6 +69,9 @@ require 'models/runtime/revision_sidecar_model' require 'models/runtime/revision_sidecar_process_type_model' require 'models/runtime/route' +require 'models/runtime/route_policy' +require 'models/runtime/route_policy_label_model' +require 'models/runtime/route_policy_annotation_model' require 'models/runtime/space_routes' require 'models/runtime/space_quota_definition' require 'models/runtime/stack' diff --git a/app/models/runtime/route.rb b/app/models/runtime/route.rb index bdefff78c41..76a5b189f07 100644 --- a/app/models/runtime/route.rb +++ b/app/models/runtime/route.rb @@ -39,6 +39,9 @@ class InvalidOrganizationRelation < CloudController::Errors::InvalidRelation; en add_association_dependencies route_mappings: :destroy + one_to_many :route_policies, class: 'VCAP::CloudController::RoutePolicy', key: :route_id, primary_key: :id + add_association_dependencies route_policies: :destroy + export_attributes :host, :path, :domain_guid, :space_guid, :service_instance_guid, :port, :options import_attributes :host, :path, :domain_guid, :space_guid, :app_guids, :port, :options diff --git a/app/models/runtime/route_policy.rb b/app/models/runtime/route_policy.rb new file mode 100644 index 00000000000..6b74fca0642 --- /dev/null +++ b/app/models/runtime/route_policy.rb @@ -0,0 +1,42 @@ +module VCAP::CloudController + class RoutePolicy < Sequel::Model(:route_policies) + many_to_one :route, + class: 'VCAP::CloudController::Route', + key: :route_id, + primary_key: :id, + without_guid_generation: true + + one_to_many :labels, class: 'VCAP::CloudController::RoutePolicyLabelModel', key: :resource_guid, primary_key: :guid + one_to_many :annotations, class: 'VCAP::CloudController::RoutePolicyAnnotationModel', key: :resource_guid, primary_key: :guid + + add_association_dependencies labels: :destroy + add_association_dependencies annotations: :destroy + + def validate + validates_presence :source + validates_presence :route_id + end + + def after_create + super + touch_associated_processes + end + + def after_destroy + super + touch_associated_processes + end + + private + + def touch_associated_processes + # Update the timestamp on all processes associated with this route + # This triggers Diego's ProcessesSync to pick up the route changes + return unless route + + route.apps.each do |process| + process.update(updated_at: Time.now) + end + end + end +end diff --git a/app/models/runtime/route_policy_annotation_model.rb b/app/models/runtime/route_policy_annotation_model.rb new file mode 100644 index 00000000000..ab2c7994486 --- /dev/null +++ b/app/models/runtime/route_policy_annotation_model.rb @@ -0,0 +1,11 @@ +module VCAP::CloudController + class RoutePolicyAnnotationModel < Sequel::Model(:route_policy_annotations) + set_primary_key :id + many_to_one :route_policy, + primary_key: :guid, + key: :resource_guid, + without_guid_generation: true + + include MetadataModelMixin + end +end diff --git a/app/models/runtime/route_policy_label_model.rb b/app/models/runtime/route_policy_label_model.rb new file mode 100644 index 00000000000..d56775cee34 --- /dev/null +++ b/app/models/runtime/route_policy_label_model.rb @@ -0,0 +1,9 @@ +module VCAP::CloudController + class RoutePolicyLabelModel < Sequel::Model(:route_policy_labels) + many_to_one :route_policy, + primary_key: :guid, + key: :resource_guid, + without_guid_generation: true + include MetadataModelMixin + end +end diff --git a/app/presenters/v3/domain_presenter.rb b/app/presenters/v3/domain_presenter.rb index 9ffa51fa951..4b4900a6660 100644 --- a/app/presenters/v3/domain_presenter.rb +++ b/app/presenters/v3/domain_presenter.rb @@ -20,7 +20,7 @@ def initialize( end def to_hash - { + hash = { guid: domain.guid, created_at: domain.created_at, updated_at: domain.updated_at, @@ -42,6 +42,13 @@ def to_hash }, links: build_links } + + if domain.enforce_route_policies + hash[:enforce_route_policies] = true + hash[:route_policies_scope] = domain.route_policies_scope + end + + hash end private diff --git a/app/presenters/v3/route_policy_presenter.rb b/app/presenters/v3/route_policy_presenter.rb new file mode 100644 index 00000000000..81904f61bba --- /dev/null +++ b/app/presenters/v3/route_policy_presenter.rb @@ -0,0 +1,74 @@ +require 'presenters/v3/base_presenter' +require 'presenters/mixins/metadata_presentation_helpers' + +module VCAP::CloudController + module Presenters + module V3 + class RoutePolicyPresenter < BasePresenter + include VCAP::CloudController::Presenters::Mixins::MetadataPresentationHelpers + + def to_hash + { + guid: route_policy.guid, + created_at: route_policy.created_at, + updated_at: route_policy.updated_at, + source: route_policy.source, + metadata: { + labels: hashified_labels(route_policy.labels), + annotations: hashified_annotations(route_policy.annotations) + }, + relationships: build_relationships, + links: build_links + } + end + + private + + def route_policy + @resource + end + + def build_relationships + relationships = { + route: { + data: { + guid: route_policy.route.guid + } + } + } + + # Extract resource GUID from source and populate read-only relationships + # The guid is included as-is without per-row existence checks to avoid N+1 queries. + # Use ?include=source to get full resource details with batch loading. + source_match = route_policy.source.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) + if source_match + resource_type = source_match[1] + resource_guid = source_match[2] + + relationships[:app] = { data: resource_type == 'app' ? { guid: resource_guid } : nil } + relationships[:space] = { data: resource_type == 'space' ? { guid: resource_guid } : nil } + relationships[:organization] = { data: resource_type == 'org' ? { guid: resource_guid } : nil } + else + # cf:any or malformed - all relationships are null + relationships[:app] = { data: nil } + relationships[:space] = { data: nil } + relationships[:organization] = { data: nil } + end + + relationships + end + + def build_links + { + self: { + href: url_builder.build_url(path: "/v3/route_policies/#{route_policy.guid}") + }, + route: { + href: url_builder.build_url(path: "/v3/routes/#{route_policy.route.guid}") + } + } + end + end + end + end +end diff --git a/app/presenters/v3/route_presenter.rb b/app/presenters/v3/route_presenter.rb index c090fafae5b..f9bac40fdba 100644 --- a/app/presenters/v3/route_presenter.rb +++ b/app/presenters/v3/route_presenter.rb @@ -48,11 +48,16 @@ def to_hash }, links: build_links } - hash.merge!(options: route.options) unless route.options.nil? + unless route.options.nil? + public_options = route.options.reject { |k, _| INTERNAL_ROUTE_OPTIONS.include?(k.to_s) } + hash.merge!(options: public_options) if route.options.empty? || public_options.present? + end @decorators.reduce(hash) { |memo, d| d.decorate(memo, [route]) } end + INTERNAL_ROUTE_OPTIONS = %w[route_policy_scope route_policy_sources].freeze + private def route diff --git a/config/routes.rb b/config/routes.rb index dc1039c54c4..28526281d86 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -338,6 +338,13 @@ post '/roles', to: 'roles#create' delete '/roles/:guid', to: 'roles#destroy' + # route_policies + get '/route_policies', to: 'route_policies#index' + get '/route_policies/:guid', to: 'route_policies#show' + post '/route_policies', to: 'route_policies#create' + patch '/route_policies/:guid', to: 'route_policies#update' + delete '/route_policies/:guid', to: 'route_policies#destroy' + # info get '/info', to: 'info#v3_info' get '/info/usage_summary', to: 'info#show_usage_summary' diff --git a/db/migrations/20260421074454_add_enforce_route_policies_to_domains.rb b/db/migrations/20260421074454_add_enforce_route_policies_to_domains.rb new file mode 100644 index 00000000000..dd5518a95c6 --- /dev/null +++ b/db/migrations/20260421074454_add_enforce_route_policies_to_domains.rb @@ -0,0 +1,15 @@ +Sequel.migration do + up do + alter_table :domains do + add_column :enforce_route_policies, :boolean, default: false, null: false unless @db.schema(:domains).map(&:first).include?(:enforce_route_policies) + add_column :route_policies_scope, String, null: true, size: 255 unless @db.schema(:domains).map(&:first).include?(:route_policies_scope) + end + end + + down do + alter_table :domains do + drop_column :enforce_route_policies if @db.schema(:domains).map(&:first).include?(:enforce_route_policies) + drop_column :route_policies_scope if @db.schema(:domains).map(&:first).include?(:route_policies_scope) + end + end +end diff --git a/db/migrations/20260421074455_create_route_policies.rb b/db/migrations/20260421074455_create_route_policies.rb new file mode 100644 index 00000000000..5e7c794c6ad --- /dev/null +++ b/db/migrations/20260421074455_create_route_policies.rb @@ -0,0 +1,58 @@ +Sequel.migration do + up do + unless table_exists?(:route_policies) + create_table :route_policies do + primary_key :id, name: :id + String :guid, size: 255, null: false + String :source, size: 255, null: false + Integer :route_id, null: false + DateTime :created_at, null: false + DateTime :updated_at, null: false + + index :guid, unique: true, name: :route_policies_guid_index + index %i[route_id source], unique: true, name: :route_policies_route_id_source_index + foreign_key [:route_id], :routes, on_delete: :cascade, name: :fk_route_policies_route_id + end + end + + unless table_exists?(:route_policy_labels) + create_table :route_policy_labels do + primary_key :id, name: :id + String :guid, null: false, size: 255 + String :resource_guid, null: false, size: 255 + String :key_prefix, null: false, default: '', size: 253 + String :key_name, null: false, size: 63 + String :value, null: false, size: 63 + DateTime :created_at, null: false + DateTime :updated_at + + index :guid, unique: true, name: :route_policy_labels_guid_index + index :resource_guid, name: :route_policy_labels_resource_guid_index + index %i[resource_guid key_prefix key_name], unique: true, name: :route_policy_labels_compound_index + foreign_key [:resource_guid], :route_policies, key: :guid, on_delete: :cascade, name: :fk_route_policy_labels_resource_guid + end + end + + unless table_exists?(:route_policy_annotations) + create_table :route_policy_annotations do + primary_key :id, name: :id + String :guid, null: false, size: 255 + String :resource_guid, null: false, size: 255 + String :key_prefix, null: false, default: '', size: 253 + String :key_name, null: false, size: 63 + String :value, size: 5000 + DateTime :created_at, null: false + DateTime :updated_at + + index :guid, unique: true, name: :route_policy_annotations_guid_index + index :resource_guid, name: :route_policy_annotations_resource_guid_index + index %i[resource_guid key_prefix key_name], unique: true, name: :route_policy_annotations_key_index + foreign_key [:resource_guid], :route_policies, key: :guid, on_delete: :cascade, name: :fk_route_policy_annotations_resource_guid + end + end + end + + down do + %i[route_policy_annotations route_policy_labels route_policies].each { |t| drop_table(t) if table_exists?(t) } + end +end diff --git a/devbox.d/mysql80/my.cnf b/devbox.d/mysql80/my.cnf new file mode 100644 index 00000000000..a749c470084 --- /dev/null +++ b/devbox.d/mysql80/my.cnf @@ -0,0 +1,6 @@ +# MySQL configuration file + +# [mysqld] +# skip-log-bin +# Change this port if 3306 is already used +#port = 3306 diff --git a/devbox.json b/devbox.json new file mode 100644 index 00000000000..24f56d6ef28 --- /dev/null +++ b/devbox.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", + "packages": [ + "ruby@3.3", + "bundler@latest", + "libpq@latest", + "openssl@latest", + "libyaml@latest", + "pkg-config@latest", + "zstd@latest", + "postgresql@latest" + ], + "shell": { + "init_hook": [ + "# Devbox installs only the default nix output (runtime libs). Native Ruby gem", + "# extensions need dev headers and pkg-config files from the -dev outputs.", + "# This hook reads -dev output paths from devbox.lock and adds them to the", + "# compiler search paths.", + "", + "LIBRARY_PATH=\"$DEVBOX_PACKAGES_DIR/lib${LIBRARY_PATH:+:$LIBRARY_PATH}\"", + "C_INCLUDE_PATH=\"$DEVBOX_PACKAGES_DIR/include${C_INCLUDE_PATH:+:$C_INCLUDE_PATH}\"", + "", + "_devbox_realize_dev_outputs() {", + " local lockfile=\"$PWD/devbox.lock\"", + " [ -f \"$lockfile\" ] || return", + " local arch=$(uname -m)", + " local os=$(uname -s | tr '[:upper:]' '[:lower:]')", + " local system=\"${arch}-${os}\"", + "", + " # Extract -dev and -out paths from devbox.lock using ruby (available in our shell)", + " local dev_paths", + " dev_paths=$(ruby -rjson -e '", + " lock = JSON.parse(File.read(ARGV[0]))", + " sys = ARGV[1]", + " lock.fetch(\"packages\", {}).each do |_, info|", + " outputs = info.dig(\"systems\", sys, \"outputs\") || []", + " outputs.each do |o|", + " # Include dev outputs, and also \"out\" outputs that contain includes", + " puts o[\"path\"] if o[\"name\"] == \"dev\" || o[\"name\"] == \"out\"", + " end", + " end", + " end", + " ' \"$lockfile\" \"$system\" 2>/dev/null)", + "", + " local p", + " for p in $dev_paths; do", + " # Realize (download) the store path if not already present", + " [ -d \"$p\" ] || nix-store --realise \"$p\" >/dev/null 2>&1 || continue", + " [ -d \"$p/include\" ] && C_INCLUDE_PATH=\"${p}/include:$C_INCLUDE_PATH\"", + " [ -d \"$p/lib/pkgconfig\" ] && PKG_CONFIG_PATH=\"${p}/lib/pkgconfig:${PKG_CONFIG_PATH:-}\"", + " [ -d \"$p/lib\" ] && LIBRARY_PATH=\"${p}/lib:$LIBRARY_PATH\"", + " done", + "}", + "", + "_devbox_realize_dev_outputs", + "export C_INCLUDE_PATH LIBRARY_PATH PKG_CONFIG_PATH", + "unset -f _devbox_realize_dev_outputs", + "", + "# Set database connection prefix for PostgreSQL tests", + "export POSTGRES_CONNECTION_PREFIX=\"postgres://postgres:supersecret@localhost:5432\"", + "export DB=postgres" + ], + "scripts": { + "test": [ + "echo \"Error: no test specified\" && exit 1" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 00000000000..beb48f6567f --- /dev/null +++ b/devbox.lock @@ -0,0 +1,783 @@ +{ + "lockfile_version": "1", + "packages": { + "bundler@latest": { + "last_modified": "2026-03-21T07:29:51Z", + "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#bundler", + "source": "devbox-search", + "version": "2.7.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/gbx73y8di7f17i727k6s0l2f1618pza8-bundler-2.7.2", + "default": true + } + ], + "store_path": "/nix/store/gbx73y8di7f17i727k6s0l2f1618pza8-bundler-2.7.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qb2ksb9khr8dpc46h2ajg85zirgz136k-bundler-2.7.2", + "default": true + } + ], + "store_path": "/nix/store/qb2ksb9khr8dpc46h2ajg85zirgz136k-bundler-2.7.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/b04xgfhjcgg1cl1h2bpjm3fds46vgd1w-bundler-2.7.2", + "default": true + } + ], + "store_path": "/nix/store/b04xgfhjcgg1cl1h2bpjm3fds46vgd1w-bundler-2.7.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/bc7ankvbv1s5shlllxxlcsgdj0b16p36-bundler-2.7.2", + "default": true + } + ], + "store_path": "/nix/store/bc7ankvbv1s5shlllxxlcsgdj0b16p36-bundler-2.7.2" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-03-16T02:27:38Z", + "resolved": "github:NixOS/nixpkgs/f8573b9c935cfaa162dd62cc9e75ae2db86f85df?lastModified=1773628058&narHash=sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY%3D" + }, + "glibcLocales@latest": { + "last_modified": "2026-03-21T07:29:51Z", + "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#glibcLocales", + "source": "devbox-search", + "version": "2.42-51", + "systems": { + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d4vnp0fbrsvijnx5ac86bbxg3bnblz7k-glibc-locales-2.42-51", + "default": true + } + ], + "store_path": "/nix/store/d4vnp0fbrsvijnx5ac86bbxg3bnblz7k-glibc-locales-2.42-51" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ld2j2mq254m77hwy22pyvrs861ap1374-glibc-locales-2.42-51", + "default": true + } + ], + "store_path": "/nix/store/ld2j2mq254m77hwy22pyvrs861ap1374-glibc-locales-2.42-51" + } + } + }, + "libpq@latest": { + "last_modified": "2026-04-11T06:17:25Z", + "resolved": "github:NixOS/nixpkgs/13043924aaa7375ce482ebe2494338e058282925#libpq", + "source": "devbox-search", + "version": "18.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/yqv7xkfakqfccbgdmkf7xhkda2yzrqd8-libpq-18.2", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/sl9kw8cqc669py9xb83c1baf342l97r5-libpq-18.2-dev" + } + ], + "store_path": "/nix/store/yqv7xkfakqfccbgdmkf7xhkda2yzrqd8-libpq-18.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/4hpn6899j9vh3p9z424vzgqn4ya4lcvv-libpq-18.2", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/xjzx6272qsnbrgmbm3yw1xb3688p5sjb-libpq-18.2-debug" + }, + { + "name": "dev", + "path": "/nix/store/fyaw62ldhlyjcnbdli0y4a9wbrlg5q78-libpq-18.2-dev" + } + ], + "store_path": "/nix/store/4hpn6899j9vh3p9z424vzgqn4ya4lcvv-libpq-18.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ig7ycilx8a3xal5dharihg0mk15yqwmv-libpq-18.2", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/dvgl05rjdbdk2ck90ccnb8g2hpyhmbbj-libpq-18.2-dev" + } + ], + "store_path": "/nix/store/ig7ycilx8a3xal5dharihg0mk15yqwmv-libpq-18.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/4fi462vs62ycv752lck8v3d70f3blh2x-libpq-18.2", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/gdmv8c5ax77873s7090b3wcicd6i4m51-libpq-18.2-dev" + }, + { + "name": "debug", + "path": "/nix/store/1ljsii50mrkvxnsvq123a9gnqj0cl8ng-libpq-18.2-debug" + } + ], + "store_path": "/nix/store/4fi462vs62ycv752lck8v3d70f3blh2x-libpq-18.2" + } + } + }, + "libyaml@latest": { + "last_modified": "2026-03-21T07:29:51Z", + "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#libyaml", + "source": "devbox-search", + "version": "0.2.5", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qqa8q98n3hvb2kqz3xvd0m0j22033wy0-libyaml-0.2.5", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/00yv9nvsx0vswzzihkkl4qk39lb2p1pc-libyaml-0.2.5-dev" + } + ], + "store_path": "/nix/store/qqa8q98n3hvb2kqz3xvd0m0j22033wy0-libyaml-0.2.5" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/m4j56in3n01xw1jk5h1qxj5r8i4x2mfb-libyaml-0.2.5", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/jyvgsbxnppxyvvgga304iw6xlhi39r17-libyaml-0.2.5-dev" + } + ], + "store_path": "/nix/store/m4j56in3n01xw1jk5h1qxj5r8i4x2mfb-libyaml-0.2.5" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/4djwsl28pfclczbkcrdqxlqrcxyvihik-libyaml-0.2.5", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/6i8a2m6yj122s9r1nyl8grxizq3av6z6-libyaml-0.2.5-dev" + } + ], + "store_path": "/nix/store/4djwsl28pfclczbkcrdqxlqrcxyvihik-libyaml-0.2.5" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/fnhs945sa02bg7gki8a3l8r9r44ylx10-libyaml-0.2.5", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/v9qn9g4fm4818vx30kl7z423vj1mswml-libyaml-0.2.5-dev" + } + ], + "store_path": "/nix/store/fnhs945sa02bg7gki8a3l8r9r44ylx10-libyaml-0.2.5" + } + } + }, + "openssl@latest": { + "last_modified": "2025-12-05T06:24:47Z", + "resolved": "github:NixOS/nixpkgs/42e29df35be6ef54091d3a3b4e97056ce0a98ce8#openssl", + "source": "devbox-search", + "version": "3.6.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/z9prisxci5h5lsk3rdknd4jzq7k9q13d-openssl-3.6.0-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/ii9mnzr3i92mgk9dkgg65739mavd0j6f-openssl-3.6.0-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/h0qgqik0mk0wn7rmm2kk3grfi1wzly74-openssl-3.6.0-dev" + }, + { + "name": "doc", + "path": "/nix/store/yx3ip21fdaaxpjn5fbir02mqnaw9cm4f-openssl-3.6.0-doc" + }, + { + "name": "out", + "path": "/nix/store/3z54dgks2mz3dhwddj158sdibll8xmq5-openssl-3.6.0" + } + ], + "store_path": "/nix/store/z9prisxci5h5lsk3rdknd4jzq7k9q13d-openssl-3.6.0-bin" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/wb6q44n9kcb5acmaa4rgqsajadx1fhhl-openssl-3.6.0-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/c9n1alb7ypzjvzd47m16fiwfczz23qs3-openssl-3.6.0-man", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/ci6d4k1sj4bnr892lsrqqmjiihqsk0bl-openssl-3.6.0-debug" + }, + { + "name": "dev", + "path": "/nix/store/pq8b7fb3282g68pmk14mbyi20qn6chid-openssl-3.6.0-dev" + }, + { + "name": "doc", + "path": "/nix/store/vaplp6w56dyz38986bgkf0pbg3r486b2-openssl-3.6.0-doc" + }, + { + "name": "out", + "path": "/nix/store/nj50gkyx813dxvfmsg1q8m330hmf3h86-openssl-3.6.0" + } + ], + "store_path": "/nix/store/wb6q44n9kcb5acmaa4rgqsajadx1fhhl-openssl-3.6.0-bin" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/m3xwn9n0jypwjgi256idfzs979g30j29-openssl-3.6.0-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/hw43f3y1vl7ydrd4samnwnrwqqwkpisv-openssl-3.6.0-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/dirjrfjk8jgsbdpslgb51cav6qaxn2vm-openssl-3.6.0-dev" + }, + { + "name": "doc", + "path": "/nix/store/va1zhkz0nfmycvd0h239hi4w40qgaxcx-openssl-3.6.0-doc" + }, + { + "name": "out", + "path": "/nix/store/q9a4wssx24xsy28w8kifdqizc01fh7sc-openssl-3.6.0" + } + ], + "store_path": "/nix/store/m3xwn9n0jypwjgi256idfzs979g30j29-openssl-3.6.0-bin" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/a9jdl6xq9fc98ykpvqmc9kf0b0j9y8wh-openssl-3.6.0-man", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/sqv8kbdgfxlr2d6nysr8c2715qpsi6f5-openssl-3.6.0-debug" + }, + { + "name": "dev", + "path": "/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev" + }, + { + "name": "doc", + "path": "/nix/store/cgp9ig35iwicfb9spcrgyg2m5dmlcgrv-openssl-3.6.0-doc" + }, + { + "name": "out", + "path": "/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0" + } + ], + "store_path": "/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin" + } + } + }, + "pkg-config@latest": { + "last_modified": "2025-11-23T21:50:36Z", + "resolved": "github:NixOS/nixpkgs/ee09932cedcef15aaf476f9343d1dea2cb77e261#pkg-config", + "source": "devbox-search", + "version": "0.29.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2", + "default": true + }, + { + "name": "man", + "path": "/nix/store/9px0sji43x3r2w4zxl3j3idwsql7lwxx-pkg-config-wrapper-0.29.2-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/hqk44ra6qxw7iixardl6c3hdgb9kq6ns-pkg-config-wrapper-0.29.2-doc" + } + ], + "store_path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2", + "default": true + }, + { + "name": "man", + "path": "/nix/store/10060k24qggqyzlwdsfmni9y32zxcg0j-pkg-config-wrapper-0.29.2-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/0y4v51ndpyvkj09hwlfqkz0c3h17zfmc-pkg-config-wrapper-0.29.2-doc" + } + ], + "store_path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2", + "default": true + }, + { + "name": "man", + "path": "/nix/store/1nyspra675q22gfhf7hn2nmfpi6rgim5-pkg-config-wrapper-0.29.2-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/7lq1axxwrafwljs06n88bzyz9w523rkc-pkg-config-wrapper-0.29.2-doc" + } + ], + "store_path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2", + "default": true + }, + { + "name": "man", + "path": "/nix/store/j9xfpnrygg3v37svc5pfin9q5bm49r94-pkg-config-wrapper-0.29.2-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/x3bypxdxaq20kykybhkf21x4jczsiy8y-pkg-config-wrapper-0.29.2-doc" + } + ], + "store_path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2" + } + } + }, + "postgresql@latest": { + "last_modified": "2026-04-11T06:17:25Z", + "plugin_version": "0.0.2", + "resolved": "github:NixOS/nixpkgs/13043924aaa7375ce482ebe2494338e058282925#postgresql", + "source": "devbox-search", + "version": "17.9", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/rcz8zc21n6rx4igsdgngmkwnln4f7dy5-postgresql-17.9", + "default": true + }, + { + "name": "man", + "path": "/nix/store/v9ad61kyx28sfzs48j9077iiv61fqzb0-postgresql-17.9-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/gillzna13al7axbhkqyjf7wwfkfbh4nn-postgresql-17.9-doc" + }, + { + "name": "jit", + "path": "/nix/store/9wrci7zgca8ygxgcg8qhk69kkk2hvnvg-postgresql-17.9-jit" + }, + { + "name": "lib", + "path": "/nix/store/h9xg40fr3hqn9lhckdf1sjp2w7zdl92n-postgresql-17.9-lib" + }, + { + "name": "dev", + "path": "/nix/store/yzvwbyh0gqrprnw5rdnhjmcmyvrl9ql4-postgresql-17.9-dev" + }, + { + "name": "plperl", + "path": "/nix/store/ywrc7vv5mdsz79z4nfid0asnzlwxp3zn-postgresql-17.9-plperl" + }, + { + "name": "plpython3", + "path": "/nix/store/jzj6b2zw28dxy8jjfvzlfbmdl8mypv2m-postgresql-17.9-plpython3" + }, + { + "name": "pltcl", + "path": "/nix/store/ya921lh5kkcrdgk09y9580prw5yg27f2-postgresql-17.9-pltcl" + } + ], + "store_path": "/nix/store/rcz8zc21n6rx4igsdgngmkwnln4f7dy5-postgresql-17.9" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/bw97pvf1fg3y7yjvw24g83448v8p48m0-postgresql-17.9", + "default": true + }, + { + "name": "man", + "path": "/nix/store/j22nri44hhgyxbg78glds0im2y608cn9-postgresql-17.9-man", + "default": true + }, + { + "name": "plpython3", + "path": "/nix/store/fha23nr7d2i16ns2z7wsrlx65fxpazxh-postgresql-17.9-plpython3" + }, + { + "name": "pltcl", + "path": "/nix/store/b9zsqpp7znmvxghjy9ihlk3p75xvd3pz-postgresql-17.9-pltcl" + }, + { + "name": "dev", + "path": "/nix/store/gvivc80vkanv4cd41r1fz0dz9qr2bsjq-postgresql-17.9-dev" + }, + { + "name": "doc", + "path": "/nix/store/39f586jzgzlkcc3dp8zajyjnf2w2mymr-postgresql-17.9-doc" + }, + { + "name": "jit", + "path": "/nix/store/iw7rjv0gjb23fwil2j0zjbghrj8bgd7q-postgresql-17.9-jit" + }, + { + "name": "plperl", + "path": "/nix/store/951fcy0jfrwz8rhi8668fqi72wwdj1qa-postgresql-17.9-plperl" + }, + { + "name": "debug", + "path": "/nix/store/rlk7xis3dfyll5z1fny70ksi3yqh1yy7-postgresql-17.9-debug" + }, + { + "name": "lib", + "path": "/nix/store/b25khikzni3m8q8nyv3mrxa5v63bqsam-postgresql-17.9-lib" + } + ], + "store_path": "/nix/store/bw97pvf1fg3y7yjvw24g83448v8p48m0-postgresql-17.9" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/jgrfz7nzjh9m5ymfv0831aamk9ci5ds9-postgresql-17.9", + "default": true + }, + { + "name": "man", + "path": "/nix/store/q824ybxz07qzwrwk9hkd16y0yl7mlp5i-postgresql-17.9-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/87c2fid7ppzyd3n3i5id3iiipybgzcp7-postgresql-17.9-doc" + }, + { + "name": "plperl", + "path": "/nix/store/p51i9h8vwml5nj6i91g0hh2zh93c4iap-postgresql-17.9-plperl" + }, + { + "name": "plpython3", + "path": "/nix/store/6a5lqzcdxiqn5nqlfddjdb921z7a35in-postgresql-17.9-plpython3" + }, + { + "name": "dev", + "path": "/nix/store/p99q8ixd6kkw2fr8zpfsmc0m3gwqcjjw-postgresql-17.9-dev" + }, + { + "name": "jit", + "path": "/nix/store/f6qm2151lg98kmayd1kddmgqv9wh1m4f-postgresql-17.9-jit" + }, + { + "name": "lib", + "path": "/nix/store/shz3ms0ww02df1k2qrzk0mv3g6ilr33j-postgresql-17.9-lib" + }, + { + "name": "pltcl", + "path": "/nix/store/cvvvm05xz8735kxb2jqh6gvxfvps1cpw-postgresql-17.9-pltcl" + } + ], + "store_path": "/nix/store/jgrfz7nzjh9m5ymfv0831aamk9ci5ds9-postgresql-17.9" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/zhaly0y0af2m7wyijyhdanm6a9l5lydv-postgresql-17.9", + "default": true + }, + { + "name": "man", + "path": "/nix/store/hgrmddv5rl1axc814n8f27q8gjlxpdz5-postgresql-17.9-man", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/yhvkyzaxm3lcs7kk8qri3ql34p6h7dmc-postgresql-17.9-debug" + }, + { + "name": "doc", + "path": "/nix/store/x41xsx8n2j3l53dr6qfr1w7i9q1pvb3b-postgresql-17.9-doc" + }, + { + "name": "plperl", + "path": "/nix/store/4dnwbih86p5grx6ys7faq29nh9w0krky-postgresql-17.9-plperl" + }, + { + "name": "plpython3", + "path": "/nix/store/rr62jngbsjqim8k5r761h985y88zci8w-postgresql-17.9-plpython3" + }, + { + "name": "pltcl", + "path": "/nix/store/292bd6aqwdsrd3bkvj8yjgwgg5nqlgjv-postgresql-17.9-pltcl" + }, + { + "name": "dev", + "path": "/nix/store/w8vci17bmzkbxclrkjxg2bd3aachf5i8-postgresql-17.9-dev" + }, + { + "name": "jit", + "path": "/nix/store/87sz1iy2q7v0fcsrgbkmryrp390v5sl9-postgresql-17.9-jit" + }, + { + "name": "lib", + "path": "/nix/store/il7gfijl01sxk16h9pffc5yan70vbqfp-postgresql-17.9-lib" + } + ], + "store_path": "/nix/store/zhaly0y0af2m7wyijyhdanm6a9l5lydv-postgresql-17.9" + } + } + }, + "ruby@3.3": { + "last_modified": "2026-01-23T17:20:52Z", + "plugin_version": "0.0.2", + "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#ruby", + "source": "devbox-search", + "version": "3.3.10", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d9wal8y7w1zpvyas3x1q4ykz880mmklk-ruby-3.3.10", + "default": true + }, + { + "name": "devdoc", + "path": "/nix/store/1rfqp0848j3gnm222ls3bipk1azcrrq3-ruby-3.3.10-devdoc" + } + ], + "store_path": "/nix/store/d9wal8y7w1zpvyas3x1q4ykz880mmklk-ruby-3.3.10" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/1hlahw0ijkxx1aqy3x41k3gxpgv34g7d-ruby-3.3.10", + "default": true + }, + { + "name": "devdoc", + "path": "/nix/store/arvi0gqvw07ngbi2ci20dn5ka2jz5irv-ruby-3.3.10-devdoc" + } + ], + "store_path": "/nix/store/1hlahw0ijkxx1aqy3x41k3gxpgv34g7d-ruby-3.3.10" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/as6xdshxpansvkag8zqr602ajkn9079z-ruby-3.3.10", + "default": true + }, + { + "name": "devdoc", + "path": "/nix/store/wix1487x3br4gxa0il4q6llz5xyqxspl-ruby-3.3.10-devdoc" + } + ], + "store_path": "/nix/store/as6xdshxpansvkag8zqr602ajkn9079z-ruby-3.3.10" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/6jz2pgmsh06z9a83qi33f6lp9w2q6mzm-ruby-3.3.10", + "default": true + }, + { + "name": "devdoc", + "path": "/nix/store/kah8xsbcd10iakxqmlw558iarhsrd5vi-ruby-3.3.10-devdoc" + } + ], + "store_path": "/nix/store/6jz2pgmsh06z9a83qi33f6lp9w2q6mzm-ruby-3.3.10" + } + } + }, + "zstd@latest": { + "last_modified": "2026-04-10T12:25:30Z", + "resolved": "github:NixOS/nixpkgs/8c11f88bb9573a10a7d6bf87161ef08455ac70b9#zstd", + "source": "devbox-search", + "version": "1.5.7", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/4crx02pb0lv0x26yly1bnbf3n00cq38m-zstd-1.5.7-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/c3g4ifcw3ad8kpa8yjs8lsac5hvmqzv0-zstd-1.5.7-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/i0hhsvlafn0zx3yl8yfcs714ps5qic00-zstd-1.5.7-dev" + }, + { + "name": "out", + "path": "/nix/store/xq7dsd7b6x66fn1pqsif0pld0nw6rb33-zstd-1.5.7" + } + ], + "store_path": "/nix/store/4crx02pb0lv0x26yly1bnbf3n00cq38m-zstd-1.5.7-bin" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/4x4q96zrz8g7jzz9wm8z94riv7q3zw0j-zstd-1.5.7-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/1xbh2v2pvphs8m06yrgzhrnrwpr0nsvl-zstd-1.5.7-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/c9082kb2i992fi80ix6zi7sa6ijqqrzv-zstd-1.5.7-dev" + }, + { + "name": "out", + "path": "/nix/store/pilcyv83zm3h2gm1924xkfmib9n63b5r-zstd-1.5.7" + } + ], + "store_path": "/nix/store/4x4q96zrz8g7jzz9wm8z94riv7q3zw0j-zstd-1.5.7-bin" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/yc6l9laqs8xs5q0ivxbr9as55x0yc9bh-zstd-1.5.7-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/gfq90rph1rzzwxkhw5pq4ywd5vy0rapa-zstd-1.5.7-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/jyyscffl8vhrgq34yl5dpf17pwz9v0d4-zstd-1.5.7-dev" + }, + { + "name": "out", + "path": "/nix/store/mdy5l0qf8z6p9xyn2igix156smcmkag8-zstd-1.5.7" + } + ], + "store_path": "/nix/store/yc6l9laqs8xs5q0ivxbr9as55x0yc9bh-zstd-1.5.7-bin" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/8cc8cdqa54bahfi5n5glkm8d252zkkjn-zstd-1.5.7-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/bhms1y19818704k4aljz6mb8prjbxd1y-zstd-1.5.7-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/q4v09bffjy5i0f2kdwnbbwmhqv6i3pjs-zstd-1.5.7-dev" + }, + { + "name": "out", + "path": "/nix/store/29mmnqpc1p3iv8wj0lpvicajy3jsbx87-zstd-1.5.7" + } + ], + "store_path": "/nix/store/8cc8cdqa54bahfi5n5glkm8d252zkkjn-zstd-1.5.7-bin" + } + } + } + } +} diff --git a/lib/cloud_controller/diego/protocol/routing_info.rb b/lib/cloud_controller/diego/protocol/routing_info.rb index e85c061a4fd..c23e33a7041 100644 --- a/lib/cloud_controller/diego/protocol/routing_info.rb +++ b/lib/cloud_controller/diego/protocol/routing_info.rb @@ -9,7 +9,7 @@ def initialize(process) end def routing_info - process_eager = ProcessModel.eager(route_mappings: { route: %i[domain route_binding] }).where(id: process.id).all + process_eager = ProcessModel.eager(route_mappings: { route: %i[domain route_binding route_policies] }).where(id: process.id).all return {} if process_eager.empty? @@ -37,17 +37,34 @@ def http_info(process_eager) end route_mappings.map do |route_mapping| - r = route_mapping.route - info = { 'hostname' => r.uri } - info['route_service_url'] = r.route_binding.route_service_url if r.route_binding && r.route_binding.route_service_url - info['router_group_guid'] = r.domain.router_group_guid if r.domain.is_a?(SharedDomain) && !r.domain.router_group_guid.nil? - info['port'] = get_port_to_use(route_mapping) - info['protocol'] = route_mapping.protocol - info['options'] = r.options if r.options - info + build_http_route_info(route_mapping) end end + def build_http_route_info(route_mapping) + r = route_mapping.route + info = { 'hostname' => r.uri } + info['route_service_url'] = r.route_binding.route_service_url if r.route_binding && r.route_binding.route_service_url + info['router_group_guid'] = r.domain.router_group_guid if r.domain.is_a?(SharedDomain) && !r.domain.router_group_guid.nil? + info['port'] = get_port_to_use(route_mapping) + info['protocol'] = route_mapping.protocol + info['options'] = r.options if r.options + + add_mtls_options(info, r) if r.domain.enforce_route_policies + + info + end + + def add_mtls_options(info, route) + # Inject mTLS policy options for enforce_route_policies domains. + # These are GoRouter-internal keys and are filtered from the /v3/routes API. + mtls_options = info['options']&.dup || {} + mtls_options['route_policy_scope'] = route.domain.route_policies_scope if route.domain.route_policies_scope + sources = route.route_policies.map(&:source) + mtls_options['route_policy_sources'] = sources.join(',') unless sources.empty? + info['options'] = mtls_options + end + def tcp_info(process_eager) route_mappings = process_eager[0].route_mappings.select do |route_mapping| r = route_mapping.route diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb new file mode 100644 index 00000000000..ebd6a69ccc4 --- /dev/null +++ b/spec/request/route_policies_spec.rb @@ -0,0 +1,673 @@ +require 'spec_helper' + +RSpec.describe 'Route Policies' do + let(:user) { VCAP::CloudController::User.make } + let(:admin_header) { admin_headers_for(user) } + let(:org) { VCAP::CloudController::Organization.make } + let(:space) { VCAP::CloudController::Space.make(organization: org) } + + let(:mtls_domain) do + VCAP::CloudController::PrivateDomain.make( + owning_organization: org, + enforce_route_policies: true, + route_policies_scope: 'space' + ) + end + let(:regular_domain) do + VCAP::CloudController::PrivateDomain.make(owning_organization: org) + end + let(:internal_domain) do + VCAP::CloudController::PrivateDomain.make( + owning_organization: org, + internal: true + ) + end + + let(:mtls_route) { VCAP::CloudController::Route.make(space: space, domain: mtls_domain) } + let(:regular_route) { VCAP::CloudController::Route.make(space: space, domain: regular_domain) } + let(:internal_route) { VCAP::CloudController::Route.make(space: space, domain: internal_domain) } + + let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } + + def expected_rule_json(rule) + { + guid: rule.guid, + created_at: iso8601, + updated_at: iso8601, + source: rule.source, + relationships: { + route: { data: { guid: rule.route.guid } } + }, + links: { + self: { href: %r{/v3/route_policies/#{rule.guid}} }, + route: { href: %r{/v3/routes/#{rule.route.guid}} } + } + } + end + + before do + TestConfig.override(kubernetes: {}) + space.organization.add_user(user) + space.add_developer(user) + end + + describe 'POST /v3/route_policies' do + let(:request_body) do + { + source: "cf:app:#{valid_uuid}", + relationships: { + route: { data: { guid: mtls_route.guid } } + } + } + end + + context 'as admin' do + it 'creates an access rule and returns 201' do + post '/v3/route_policies', request_body.to_json, admin_header + + expect(last_response.status).to eq(201) + parsed = Oj.load(last_response.body) + expect(parsed['source']).to eq("cf:app:#{valid_uuid}") + expect(parsed['relationships']['route']['data']['guid']).to eq(mtls_route.guid) + end + end + + context 'as space developer' do + let(:user_headers) { headers_for(user) } + + it 'creates an access rule' do + post '/v3/route_policies', request_body.to_json, user_headers + + expect(last_response.status).to eq(201) + end + end + + context 'when the domain does not have enforce_route_policies enabled' do + let(:request_body) do + { + source: "cf:app:#{valid_uuid}", + relationships: { + route: { data: { guid: regular_route.guid } } + } + } + end + + it 'returns 422' do + post '/v3/route_policies', request_body.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('enforce_route_policies') + end + end + + context 'when the route is on an internal domain' do + let(:request_body) do + { + source: "cf:app:#{valid_uuid}", + relationships: { + route: { data: { guid: internal_route.guid } } + } + } + end + + it 'returns 422 with a message about internal domains' do + post '/v3/route_policies', request_body.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('internal domains') + expect(last_response.body).to include('container-to-container networking') + end + end + + context 'when the route does not exist' do + let(:request_body) do + { + source: "cf:app:#{valid_uuid}", + relationships: { + route: { data: { guid: 'nonexistent-guid' } } + } + } + end + + it 'returns 404' do + post '/v3/route_policies', request_body.to_json, admin_header + + expect(last_response.status).to eq(404) + end + end + + context 'cf:any exclusivity' do + before do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'rejects cf:any when other rules exist' do + post '/v3/route_policies', { + source: 'cf:any', + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('cf:any') + end + end + + context 'when a cf:any rule already exists' do + before do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: 'cf:any', + route_id: mtls_route.id + ) + end + + it 'rejects adding a specific selector' do + post '/v3/route_policies', { + source: "cf:space:#{valid_uuid}", + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('cf:any') + end + end + + context 'duplicate selector per route' do + before do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'returns 422' do + post '/v3/route_policies', { + source: "cf:app:#{valid_uuid}", + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + end + end + + context 'invalid selector format' do + it 'returns 422' do + post '/v3/route_policies', { + source: 'not-valid', + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('Source') + end + end + + context 'when a concurrent request creates the same selector (UniqueConstraintViolation)' do + it 'returns 422 instead of 500' do + # Simulate a race condition where the DB unique constraint catches the duplicate + # after validation passes but before the insert commits + allow_any_instance_of(VCAP::CloudController::RoutePolicy).to receive(:save).and_raise( + Sequel::UniqueConstraintViolation.new('UNIQUE constraint failed: route_policies.route_id, route_policies.source') + ) + + post '/v3/route_policies', { + source: "cf:app:#{valid_uuid}", + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('already exists') + end + end + end + + describe 'GET /v3/route_policies/:guid' do + let!(:route_policy) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'returns the access rule' do + get "/v3/route_policies/#{route_policy.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['guid']).to eq(route_policy.guid) + expect(parsed['source']).to eq("cf:app:#{valid_uuid}") + end + + context 'when the access rule does not exist' do + it 'returns 404' do + get '/v3/route_policies/nonexistent-guid', nil, admin_header + + expect(last_response.status).to eq(404) + end + end + end + + describe 'GET /v3/route_policies' do + let!(:rule1) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + let!(:rule2) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: 'cf:any', + route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id + ) + end + + it 'lists all accessible access rules' do + get '/v3/route_policies', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].pluck('guid') + expect(guids).to include(rule1.guid, rule2.guid) + end + + it 'filters by route_guids' do + get "/v3/route_policies?route_guids=#{mtls_route.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].pluck('guid') + expect(guids).to include(rule1.guid) + expect(guids).not_to include(rule2.guid) + end + + it 'filters by selectors' do + get '/v3/route_policies?sources=cf:any', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(1) + expect(parsed['resources'][0]['source']).to eq('cf:any') + end + + describe 'filtering by space_guids' do + let(:other_org) { VCAP::CloudController::Organization.make } + let(:other_space) { VCAP::CloudController::Space.make(organization: other_org) } + let(:other_mtls_domain) do + VCAP::CloudController::PrivateDomain.make( + owning_organization: other_org, + enforce_route_policies: true, + route_policies_scope: 'space' + ) + end + let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) } + let!(:rule_in_other_space) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: 'cf:any', + route_id: other_route.id + ) + end + + before do + other_org.add_user(user) + other_space.add_developer(user) + end + + it 'filters by single space_guid' do + get "/v3/route_policies?space_guids=#{space.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].pluck('guid') + expect(guids).to include(rule1.guid, rule2.guid) + expect(guids).not_to include(rule_in_other_space.guid) + end + + it 'filters by multiple space_guids' do + get "/v3/route_policies?space_guids=#{space.guid},#{other_space.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].pluck('guid') + expect(guids).to include(rule1.guid, rule2.guid, rule_in_other_space.guid) + end + + it 'combines space_guids with other filters' do + get "/v3/route_policies?space_guids=#{space.guid}&sources=cf:app:#{valid_uuid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(1) + expect(parsed['resources'][0]['guid']).to eq(rule1.guid) + expect(parsed['resources'][0]['source']).to eq("cf:app:#{valid_uuid}") + end + + it 'returns empty when space has no access rules' do + empty_space = VCAP::CloudController::Space.make(organization: org) + org.add_user(user) + empty_space.add_developer(user) + + get "/v3/route_policies?space_guids=#{empty_space.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(0) + end + end + + describe 'filtering by both route_guids and space_guids' do + let(:other_org) { VCAP::CloudController::Organization.make } + let(:other_space) { VCAP::CloudController::Space.make(organization: other_org) } + let(:other_mtls_domain) do + VCAP::CloudController::PrivateDomain.make( + owning_organization: other_org, + enforce_route_policies: true, + route_policies_scope: 'space' + ) + end + let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) } + let!(:rule_in_other_space) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: 'cf:any', + route_id: other_route.id + ) + end + + before do + other_org.add_user(user) + other_space.add_developer(user) + end + + it 'returns results matching both route_guids and space_guids without ambiguous column errors' do + get "/v3/route_policies?route_guids=#{mtls_route.guid}&space_guids=#{space.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].pluck('guid') + expect(guids).to include(rule1.guid) + expect(guids).not_to include(rule_in_other_space.guid) + end + end + + describe 'filtering by source_guids' do + it 'escapes % so it does not act as a LIKE wildcard' do + get '/v3/route_policies?source_guids=%25', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(0) + end + + it 'escapes _ so it does not act as a LIKE single-char wildcard' do + get '/v3/route_policies?source_guids=cf_app', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + # _ would match any single char (e.g. "cf:app"), but escaped it matches literal "_" + expect(parsed['resources'].length).to eq(0) + end + + it 'escapes backslash so it does not act as a LIKE escape character' do + get '/v3/route_policies?source_guids=cf%5Capp', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(0) + end + end + + context 'with include=source' do + let!(:frontend_app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } + let!(:other_space) { VCAP::CloudController::Space.make(organization: org, name: 'other-space') } + let!(:other_org) { VCAP::CloudController::Organization.make(name: 'other-org') } + + let!(:app_rule) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{frontend_app.guid}", + route_id: mtls_route.id + ) + end + + let!(:space_rule) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:space:#{other_space.guid}", + route_id: mtls_route.id + ) + end + + let!(:org_rule) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:org:#{other_org.guid}", + route_id: mtls_route.id + ) + end + + it 'includes resolved selector resources' do + get '/v3/route_policies?include=source', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Check included structure + expect(parsed['included']).to be_a(Hash) + expect(parsed['included']['apps']).to be_an(Array) + expect(parsed['included']['spaces']).to be_an(Array) + expect(parsed['included']['organizations']).to be_an(Array) + + # Check app is included with full details + app_included = parsed['included']['apps'].find { |a| a['guid'] == frontend_app.guid } + expect(app_included).to be_present + expect(app_included['name']).to eq('frontend-app') + expect(app_included['guid']).to eq(frontend_app.guid) + + # Check space is included + space_included = parsed['included']['spaces'].find { |s| s['guid'] == other_space.guid } + expect(space_included).to be_present + expect(space_included['name']).to eq('other-space') + + # Check org is included + org_included = parsed['included']['organizations'].find { |o| o['guid'] == other_org.guid } + expect(org_included).to be_present + expect(org_included['name']).to eq('other-org') + end + + it 'handles stale resources (missing GUIDs) gracefully' do + stale_guid = '99999999-9999-9999-9999-999999999999' + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{stale_guid}", + route_id: mtls_route.id + ) + + get '/v3/route_policies?include=source', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Stale resource should not appear in included + stale_app = parsed['included']['apps'].find { |a| a['guid'] == stale_guid } + expect(stale_app).to be_nil + end + + it 'includes only unique resources when multiple rules reference the same resource' do + # Create another rule referencing the same app + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{frontend_app.guid}", + route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id + ) + + get '/v3/route_policies?include=source', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # App should appear only once + app_count = parsed['included']['apps'].count { |a| a['guid'] == frontend_app.guid } + expect(app_count).to eq(1) + end + + it 'does not include resources for cf:any selectors' do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: 'cf:any', + route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id + ) + + get '/v3/route_policies?include=source', nil, admin_header + + expect(last_response.status).to eq(200) + # Should succeed without error even with cf:any selector + end + end + + context 'with include=route' do + let(:route2) { VCAP::CloudController::Route.make(space: space, domain: mtls_domain) } + + let!(:rule_on_route1) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: 'cf:any', + route_id: mtls_route.id + ) + end + + let!(:rule_on_route2) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{valid_uuid}", + route_id: route2.id + ) + end + + it 'includes route resources' do + get '/v3/route_policies?include=route', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Check included structure + expect(parsed['included']).to be_a(Hash) + expect(parsed['included']['routes']).to be_an(Array) + expect(parsed['included']['routes'].length).to be >= 2 + + # Check routes are included with full details + route1_included = parsed['included']['routes'].find { |r| r['guid'] == mtls_route.guid } + expect(route1_included).to be_present + expect(route1_included['guid']).to eq(mtls_route.guid) + expect(route1_included['url']).to be_present + + route2_included = parsed['included']['routes'].find { |r| r['guid'] == route2.guid } + expect(route2_included).to be_present + expect(route2_included['guid']).to eq(route2.guid) + end + + it 'includes only unique routes when multiple rules reference the same route' do + # Create another rule on the same route with a different selector + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{SecureRandom.uuid}", + route_id: mtls_route.id + ) + + get '/v3/route_policies?include=route', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Route should appear only once + route_count = parsed['included']['routes'].count { |r| r['guid'] == mtls_route.guid } + expect(route_count).to eq(1) + end + + it 'combines include=route with include=source' do + test_app = VCAP::CloudController::AppModel.make(space: space, name: 'test-app') + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{test_app.guid}", + route_id: mtls_route.id + ) + + get '/v3/route_policies?include=route,source', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Both routes and selector resources should be included + expect(parsed['included']['routes']).to be_an(Array) + expect(parsed['included']['apps']).to be_an(Array) + + # Verify route is present + route_included = parsed['included']['routes'].find { |r| r['guid'] == mtls_route.guid } + expect(route_included).to be_present + + # Verify app is present + app_included = parsed['included']['apps'].find { |a| a['guid'] == test_app.guid } + expect(app_included).to be_present + end + end + end + + describe 'DELETE /v3/route_policies/:guid' do + let!(:route_policy) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'deletes the access rule and returns 204' do + delete "/v3/route_policies/#{route_policy.guid}", nil, admin_header + + expect(last_response.status).to eq(204) + expect(VCAP::CloudController::RoutePolicy.find(guid: route_policy.guid)).to be_nil + end + + context 'when the access rule does not exist' do + it 'returns 404' do + delete '/v3/route_policies/nonexistent-guid', nil, admin_header + + expect(last_response.status).to eq(404) + end + end + end + + describe 'PATCH /v3/route_policies/:guid (metadata update)' do + let!(:route_policy) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'returns 200' do + patch "/v3/route_policies/#{route_policy.guid}", { + metadata: { labels: { env: 'production' } } + }.to_json, admin_header + + expect(last_response.status).to eq(200) + end + + context 'when the access rule does not exist' do + it 'returns 404' do + patch '/v3/route_policies/nonexistent-guid', {}.to_json, admin_header + + expect(last_response.status).to eq(404) + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb index d74502cf615..b33b1526704 100644 --- a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb @@ -250,6 +250,62 @@ class Protocol it 'does not include the internal routes' do end end + + context 'when the route domain has enforce_route_policies enabled' do + let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } + let(:enforce_domain) do + PrivateDomain.make( + name: 'mtls.example.com', + owning_organization: org, + enforce_route_policies: true, + route_policies_scope: 'space' + ) + end + let(:mtls_route) { Route.make(host: 'myapp', domain: enforce_domain, space: space) } + let!(:access_rule1) do + RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + let!(:access_rule2) do + RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:space:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + before do + RouteMappingModel.make(app: process.app, route: mtls_route, process_type: process.type) + end + + it 'injects access_scope and access_rules into route options' do + http_routes = ri['http_routes'] + mtls_entry = http_routes.find { |r| r['hostname'] == 'myapp.mtls.example.com' } + + expect(mtls_entry).not_to be_nil + expect(mtls_entry['options']['route_policy_scope']).to eq('space') + expect(mtls_entry['options']['route_policy_sources']).to include("cf:app:#{valid_uuid}") + expect(mtls_entry['options']['route_policy_sources']).to include("cf:space:#{valid_uuid}") + end + + context 'when the route has no access rules' do + before do + access_rule1.destroy + access_rule2.destroy + end + + it 'injects access_scope but omits access_rules key' do + http_routes = ri['http_routes'] + mtls_entry = http_routes.find { |r| r['hostname'] == 'myapp.mtls.example.com' } + + expect(mtls_entry['options']['route_policy_scope']).to eq('space') + expect(mtls_entry['options']).not_to have_key('route_policy_sources') + end + end + end end context 'tcp routes' do diff --git a/spec/unit/messages/domain_create_message_spec.rb b/spec/unit/messages/domain_create_message_spec.rb index f7dae8db280..cf71ae6936b 100644 --- a/spec/unit/messages/domain_create_message_spec.rb +++ b/spec/unit/messages/domain_create_message_spec.rb @@ -403,6 +403,93 @@ module VCAP::CloudController expect(subject).to be_valid end end + + context 'enforce_route_policies' do + context 'when not a boolean' do + let(:params) { { name: 'name.com', enforce_route_policies: 'yes' } } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:enforce_route_policies]).to include('must be a boolean') + end + end + + context 'when true without route_policies_scope' do + let(:params) { { name: 'name.com', enforce_route_policies: true } } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:route_policies_scope]).to include('is required when enforce_route_policies is true') + end + end + + context 'when true with a valid route_policies_scope' do + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'space' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when false without route_policies_scope' do + let(:params) { { name: 'name.com', enforce_route_policies: false } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when omitted' do + let(:params) { { name: 'name.com' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + end + + context 'route_policies_scope' do + context 'when set to an invalid value' do + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'invalid' } } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:route_policies_scope]).to include("must be one of 'any', 'org', 'space'") + end + end + + context "when set to 'any'" do + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'any' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context "when set to 'org'" do + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'org' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context "when set to 'space'" do + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'space' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when provided without enforce_route_policies' do + let(:params) { { name: 'name.com', route_policies_scope: 'space' } } + + it 'is valid (scope alone is permissible)' do + expect(subject).to be_valid + end + end + end end describe 'accessor methods' do diff --git a/spec/unit/messages/route_policies_list_message_spec.rb b/spec/unit/messages/route_policies_list_message_spec.rb new file mode 100644 index 00000000000..0a502105db7 --- /dev/null +++ b/spec/unit/messages/route_policies_list_message_spec.rb @@ -0,0 +1,167 @@ +require 'spec_helper' +require 'messages/route_policies_list_message' + +module VCAP::CloudController + RSpec.describe RoutePoliciesListMessage do + describe '.from_params' do + let(:params) do + { + 'guids' => 'guid1,guid2', + 'route_guids' => 'route1,route2', + 'space_guids' => 'space1,space2', + 'sources' => 'source1,source2', + 'source_guids' => 'resource1,resource2', + 'page' => 1, + 'per_page' => 5, + 'order_by' => 'created_at', + 'include' => 'source,route,app,space,organization' + } + end + + it 'returns the correct RoutePoliciesListMessage' do + message = RoutePoliciesListMessage.from_params(params) + + expect(message).to be_a(RoutePoliciesListMessage) + expect(message.guids).to eq(%w[guid1 guid2]) + expect(message.route_guids).to eq(%w[route1 route2]) + expect(message.space_guids).to eq(%w[space1 space2]) + expect(message.sources).to eq(%w[source1 source2]) + expect(message.source_guids).to eq(%w[resource1 resource2]) + expect(message.page).to eq(1) + expect(message.per_page).to eq(5) + expect(message.order_by).to eq('created_at') + expect(message.include).to eq(%w[source route app space organization]) + end + + it 'converts requested keys to symbols' do + message = RoutePoliciesListMessage.from_params(params) + + expect(message).to be_requested(:guids) + expect(message).to be_requested(:route_guids) + expect(message).to be_requested(:space_guids) + expect(message).to be_requested(:sources) + expect(message).to be_requested(:source_guids) + expect(message).to be_requested(:page) + expect(message).to be_requested(:per_page) + expect(message).to be_requested(:order_by) + expect(message).to be_requested(:include) + end + end + + describe '#to_param_hash' do + let(:opts) do + { + guids: %w[guid1 guid2], + route_guids: %w[route1 route2], + space_guids: %w[space1 space2], + sources: %w[source1 source2], + source_guids: %w[resource1 resource2], + page: 1, + per_page: 5, + order_by: 'created_at', + include: %w[source route app space organization] + } + end + + it 'excludes the pagination keys' do + expected_params = %i[guids route_guids space_guids sources source_guids include] + expect(RoutePoliciesListMessage.from_params(opts).to_param_hash.keys).to match_array(expected_params) + end + end + + describe 'fields' do + it 'accepts a set of fields' do + expect do + RoutePoliciesListMessage.from_params({ + guids: [], + route_guids: [], + space_guids: [], + sources: [], + source_guids: [], + page: 1, + per_page: 5, + order_by: 'created_at', + include: %w[source route app space organization] + }) + end.not_to raise_error + end + + it 'accepts an empty set' do + message = RoutePoliciesListMessage.from_params({}) + expect(message).to be_valid + end + + it 'does not accept a field not in this set' do + message = RoutePoliciesListMessage.from_params({ foobar: 'pants' }) + + expect(message).not_to be_valid + expect(message.errors[:base][0]).to include("Unknown query parameter(s): 'foobar'") + end + + describe 'include validations' do + it 'accepts valid include values' do + message = RoutePoliciesListMessage.from_params({ 'include' => 'source' }) + expect(message).to be_valid + + message = RoutePoliciesListMessage.from_params({ 'include' => 'route' }) + expect(message).to be_valid + + message = RoutePoliciesListMessage.from_params({ 'include' => 'app' }) + expect(message).to be_valid + + message = RoutePoliciesListMessage.from_params({ 'include' => 'space' }) + expect(message).to be_valid + + message = RoutePoliciesListMessage.from_params({ 'include' => 'organization' }) + expect(message).to be_valid + + message = RoutePoliciesListMessage.from_params({ 'include' => 'source,route,app,space,organization' }) + expect(message).to be_valid + end + + it 'rejects invalid include values' do + message = RoutePoliciesListMessage.from_params({ 'include' => 'invalid' }) + expect(message).not_to be_valid + end + end + + describe 'validations' do + it 'validates space_guids is an array' do + message = RoutePoliciesListMessage.from_params space_guids: 'not array' + expect(message).not_to be_valid + expect(message.errors[:space_guids].length).to eq 1 + end + + it 'allows space_guids to be nil' do + message = RoutePoliciesListMessage.from_params({}) + expect(message).to be_valid + expect(message.space_guids).to be_nil + end + + it 'allows space_guids to be an array' do + message = RoutePoliciesListMessage.from_params space_guids: %w[space1 space2] + expect(message).to be_valid + expect(message.space_guids).to eq(%w[space1 space2]) + end + + it 'validates source_guids is an array' do + message = RoutePoliciesListMessage.from_params source_guids: 'not array' + expect(message).not_to be_valid + expect(message.errors[:source_guids].length).to eq 1 + end + + it 'allows source_guids to be nil' do + message = RoutePoliciesListMessage.from_params({}) + expect(message).to be_valid + expect(message.source_guids).to be_nil + end + + it 'allows source_guids to be an array' do + message = RoutePoliciesListMessage.from_params source_guids: %w[guid1 guid2] + expect(message).to be_valid + expect(message.source_guids).to eq(%w[guid1 guid2]) + end + end + end + end +end diff --git a/spec/unit/messages/route_policy_create_message_spec.rb b/spec/unit/messages/route_policy_create_message_spec.rb new file mode 100644 index 00000000000..23e9e852ca2 --- /dev/null +++ b/spec/unit/messages/route_policy_create_message_spec.rb @@ -0,0 +1,204 @@ +require 'spec_helper' +require 'messages/route_policy_create_message' + +module VCAP::CloudController + RSpec.describe RoutePolicyCreateMessage do + let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } + let(:valid_route_relationship) do + { relationships: { route: { data: { guid: valid_uuid } } } } + end + + subject { RoutePolicyCreateMessage.new(params) } + + describe 'validations' do + context 'when all valid params are given' do + let(:params) do + { + source: "cf:app:#{valid_uuid}" + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when unexpected keys are provided' do + let(:params) do + { + source: "cf:app:#{valid_uuid}", + unexpected: 'field' + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors.full_messages[0]).to include("Unknown field(s): 'unexpected'") + end + end + + describe 'source' do + context 'when selector is missing' do + let(:params) do + valid_route_relationship + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:source]).to include("can't be blank") + end + end + + context 'when selector is not a string' do + let(:params) do + { + source: 123 + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:source]).to include('must be a string') + end + end + + context 'selector format' do + context 'cf:app:' do + let(:params) do + { + source: "cf:app:#{valid_uuid}" + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'cf:space:' do + let(:params) do + { + source: "cf:space:#{valid_uuid}" + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'cf:org:' do + let(:params) do + { + source: "cf:org:#{valid_uuid}" + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'cf:any' do + let(:params) do + { + source: 'cf:any' + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'invalid format' do + let(:params) do + { + source: 'not-valid' + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:source]).to include( + "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" + ) + end + end + + context 'cf:app: with invalid uuid' do + let(:params) do + { + source: 'cf:app:not-a-uuid' + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:source]).to include( + "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" + ) + end + end + + context 'cf:unknown type' do + let(:params) do + { + source: "cf:team:#{valid_uuid}" + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:source]).to include( + "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" + ) + end + end + end + end + + describe 'relationships' do + context 'when relationships is missing' do + let(:params) do + { + source: "cf:app:#{valid_uuid}" + } + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:relationships]).to be_present + end + end + + context 'when route relationship is missing' do + let(:params) do + { + source: "cf:app:#{valid_uuid}", + relationships: {} + } + end + + it 'is not valid' do + expect(subject).not_to be_valid + end + end + + context 'when route guid is provided' do + let(:params) do + { + source: "cf:app:#{valid_uuid}", + relationships: { route: { data: { guid: 'some-route-guid' } } } + } + end + + it 'exposes the route_guid' do + expect(subject).to be_valid + expect(subject.route_guid).to eq('some-route-guid') + end + end + end + end + end +end diff --git a/spec/unit/models/runtime/route_policy_spec.rb b/spec/unit/models/runtime/route_policy_spec.rb new file mode 100644 index 00000000000..2dfbc9520dc --- /dev/null +++ b/spec/unit/models/runtime/route_policy_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe RoutePolicy, type: :model do + let(:space) { Space.make } + let(:domain) { SharedDomain.make(name: 'apps.identity') } + let(:route) { Route.make(space:, domain:) } + let(:app_model) { AppModel.make(space:) } + let(:process) do + ProcessModel.make(app: app_model, type: 'web') + end + let(:app_guid) { SecureRandom.uuid } + + before do + RouteMappingModel.make(app: app_model, route: route, process_type: 'web') + end + + describe 'validations' do + it 'requires a selector' do + rule = RoutePolicy.new(route:) + expect(rule.valid?).to be false + expect(rule.errors[:source]).to include(:presence) + end + + it 'requires a route_id' do + rule = RoutePolicy.new(source: 'cf:app:123') + expect(rule.valid?).to be false + expect(rule.errors[:route_id]).to include(:presence) + end + end + + describe 'associations' do + it 'belongs to a route' do + rule = RoutePolicy.create( + source: 'cf:app:123', + route: route + ) + expect(rule.route).to eq(route) + end + end + + describe 'callbacks' do + describe 'after_create' do + it 'calls touch_associated_processes' do + expect_any_instance_of(RoutePolicy).to receive(:touch_associated_processes).and_call_original + + RoutePolicy.create( + source: "cf:app:#{app_guid}", + route: route + ) + end + + it 'updates associated processes' do + process # force creation + + # Record the SQL update queries to verify the process row is updated + RoutePolicy.create( + source: "cf:app:#{app_guid}", + route: route + ) + + # Verify the route has linked processes + expect(route.apps).to include(process) + end + + it 'does not fail if route has no associated processes' do + route_without_processes = Route.make(space:, domain:) + + expect do + RoutePolicy.create( + source: "cf:app:#{app_guid}", + route: route_without_processes + ) + end.not_to raise_error + end + end + + describe 'after_destroy' do + it 'calls touch_associated_processes' do + rule = RoutePolicy.create( + source: "cf:app:#{app_guid}", + route: route + ) + + expect_any_instance_of(RoutePolicy).to receive(:touch_associated_processes).and_call_original + + rule.destroy + end + + it 'does not fail if route has no associated processes' do + route_without_processes = Route.make(space:, domain:) + rule = RoutePolicy.create( + source: "cf:app:#{app_guid}", + route: route_without_processes + ) + + expect do + rule.destroy + end.not_to raise_error + end + end + end + end +end diff --git a/spec/unit/presenters/v3/domain_presenter_spec.rb b/spec/unit/presenters/v3/domain_presenter_spec.rb index 1ed3537e6bf..390d13644d9 100644 --- a/spec/unit/presenters/v3/domain_presenter_spec.rb +++ b/spec/unit/presenters/v3/domain_presenter_spec.rb @@ -238,6 +238,43 @@ module VCAP::CloudController::Presenters::V3 end end + context 'when the domain has enforce_route_policies enabled' do + let(:org) { VCAP::CloudController::Organization.make } + let(:domain) do + VCAP::CloudController::PrivateDomain.make( + name: 'mtls.domain.com', + owning_organization: org, + enforce_route_policies: true, + route_policies_scope: 'space' + ) + end + + it 'includes enforce_route_policies and route_policies_scope in the output' do + expect(subject[:enforce_route_policies]).to be(true) + expect(subject[:route_policies_scope]).to eq('space') + end + end + + context 'when the domain does not have enforce_route_policies enabled' do + let(:domain) do + VCAP::CloudController::SharedDomain.make( + name: 'regular.domain.com' + ) + end + + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) + end + + it 'does not include enforce_route_policies or route_policies_scope in the output' do + expect(subject).not_to have_key(:enforce_route_policies) + expect(subject).not_to have_key(:route_policies_scope) + end + end + context 'and the routing API is disabled' do before do allow(routing_api_client).to receive(:enabled?).and_return false diff --git a/spec/unit/presenters/v3/route_presenter_spec.rb b/spec/unit/presenters/v3/route_presenter_spec.rb index 684b132e407..b30378fd962 100644 --- a/spec/unit/presenters/v3/route_presenter_spec.rb +++ b/spec/unit/presenters/v3/route_presenter_spec.rb @@ -147,6 +147,44 @@ module VCAP::CloudController::Presenters::V3 end end + context 'when options contains only internal mTLS keys' do + let(:route) do + VCAP::CloudController::Route.make( + host: 'foobar', + path: path, + space: space, + domain: domain, + options: { 'route_policy_scope' => 'space', 'route_policy_sources' => 'cf:app:some-guid' } + ) + end + + it 'omits the options key entirely from the response' do + expect(subject).not_to have_key(:options) + end + end + + context 'when options contains a mix of public and internal keys' do + let(:route) do + VCAP::CloudController::Route.make( + host: 'foobar', + path: path, + space: space, + domain: domain, + options: { + 'loadbalancing' => 'round-robin', + 'route_policy_scope' => 'space', + 'route_policy_sources' => 'cf:app:some-guid' + } + ) + end + + it 'exposes only the public options' do + expect(subject[:options]).to eq('loadbalancing' => 'round-robin') + expect(subject[:options]).not_to have_key('route_policy_scope') + expect(subject[:options]).not_to have_key('route_policy_sources') + end + end + context 'when there are decorators' do let(:banana_decorator) do Class.new do