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/docs/v3/source/includes/api_resources/_domains.erb b/docs/v3/source/includes/api_resources/_domains.erb index 3c56a181965..bb330ceda33 100644 --- a/docs/v3/source/includes/api_resources/_domains.erb +++ b/docs/v3/source/includes/api_resources/_domains.erb @@ -87,6 +87,37 @@ "href": "https://api.example.org/routing/v1/router_groups/5806148f-cce6-4d86-7fbd-aa269e3f6f3f" } } + }, + { + "guid": "9b2f3d89-3f89-4f05-8188-8a2b298c79d5", + "created_at": "2026-04-21T10:15:30Z", + "updated_at": "2026-04-21T10:15:30Z", + "name": "apps.identity", + "internal": false, + "router_group": null, + "supported_protocols": ["http"], + "enforce_route_policies": true, + "route_policies_scope": "org", + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": { + "organization": { + "data": null + }, + "shared_organizations": { + "data": [] + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/domains/9b2f3d89-3f89-4f05-8188-8a2b298c79d5" + }, + "route_reservations": { + "href": "https://api.example.org/v3/domains/9b2f3d89-3f89-4f05-8188-8a2b298c79d5/route_reservations" + } + } } ] } diff --git a/docs/v3/source/includes/api_resources/_route_policies.erb b/docs/v3/source/includes/api_resources/_route_policies.erb new file mode 100644 index 00000000000..de53e9b9e63 --- /dev/null +++ b/docs/v3/source/includes/api_resources/_route_policies.erb @@ -0,0 +1,131 @@ +<% content_for :single_route_policy do | metadata={} | %> +{ + "guid": "a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f", + "created_at": "2026-04-21T10:15:30Z", + "updated_at": "2026-04-21T10:15:30Z", + "source": "cf:app:d76446a1-f429-4444-8797-be2f78b75b08", + "metadata": { + "labels": <%= metadata.fetch(:labels, {}).to_json(space: ' ', object_nl: ' ')%>, + "annotations": <%= metadata.fetch(:annotations, {}).to_json(space: ' ', object_nl: ' ')%> + }, + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + }, + "app": { + "data": { + "guid": "d76446a1-f429-4444-8797-be2f78b75b08" + } + }, + "space": { + "data": null + }, + "organization": { + "data": null + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" + }, + "route": { + "href": "https://api.example.org/v3/routes/89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } +} +<% end %> + +<% content_for :paginated_list_of_route_policies do |base_url| %> +{ + "pagination": { + "total_results": 3, + "total_pages": 2, + "first": { + "href": "https://api.example.org<%= base_url %>?page=1&per_page=2" + }, + "last": { + "href": "https://api.example.org<%= base_url %>?page=2&per_page=2" + }, + "next": { + "href": "https://api.example.org<%= base_url %>?page=2&per_page=2" + }, + "previous": null + }, + "resources": [ + { + "guid": "a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f", + "created_at": "2026-04-21T10:15:30Z", + "updated_at": "2026-04-21T10:15:30Z", + "source": "cf:app:d76446a1-f429-4444-8797-be2f78b75b08", + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + }, + "app": { + "data": { + "guid": "d76446a1-f429-4444-8797-be2f78b75b08" + } + }, + "space": { + "data": null + }, + "organization": { + "data": null + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" + }, + "route": { + "href": "https://api.example.org/v3/routes/89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + }, + { + "guid": "f2b5d8c3-92a1-4e3f-b847-9c8f1d2e3a4b", + "created_at": "2026-04-21T11:20:45Z", + "updated_at": "2026-04-21T11:20:45Z", + "source": "cf:space:3fa85f64-5717-4562-b3fc-2c963f66afa6", + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + }, + "app": { + "data": null + }, + "space": { + "data": { + "guid": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + }, + "organization": { + "data": null + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/route_policies/f2b5d8c3-92a1-4e3f-b847-9c8f1d2e3a4b" + }, + "route": { + "href": "https://api.example.org/v3/routes/89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + } + ] +} +<% end %> diff --git a/docs/v3/source/includes/resources/domains/_create.md.erb b/docs/v3/source/includes/resources/domains/_create.md.erb index 72ee53a16be..e5969843366 100644 --- a/docs/v3/source/includes/resources/domains/_create.md.erb +++ b/docs/v3/source/includes/resources/domains/_create.md.erb @@ -15,6 +15,23 @@ curl "https://api.example.org/v3/domains" \ }' ``` +``` +Example Request (Identity-Aware Domain) +``` + +```shell +curl "https://api.example.org/v3/domains" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "name": "apps.identity", + "internal": false, + "enforce_route_policies": true, + "route_policies_scope": "org" + }' +``` + ``` Example Response ``` @@ -41,6 +58,8 @@ Name | Type | Description | ----------- | -------- | ------------------------------------------------------------------------ | ------- | | **internal** | _boolean_ | Whether the domain is used for internal (container-to-container) traffic, or external (user-to-container) traffic | false | | **router_group.guid** | _uuid_ | The desired router group guid.
_note: creates a `tcp` domain; cannot be used when `internal` is set to `true` or domain is scoped to an org_ | null | +| **enforce_route_policies** | _boolean_ | When `true`, GoRouter enforces route policies for routes on this domain using mutual TLS (mTLS). **Immutable** after creation. Cannot be used with internal domains | false | +| **route_policies_scope** | _string_ | Operator-defined boundary for allowed callers: `any`, `org`, or `space`. Required when `enforce_route_policies` is `true`. **Immutable** after creation | | | **organization** | [_to-one relationship_](#to-one-relationships) | A relationship to the organization the domain will be scoped to;
_note: cannot be used when `internal` is set to `true` or domain is associated with a router group_ | | | **shared_organizations** | [_to-many relationship_](#to-many-relationships) | A relationship to organizations the domain will be shared with
_Note: cannot be used without an organization relationship_ | | | **metadata.labels** | [_label object_](#labels) | Labels applied to the domain | | diff --git a/docs/v3/source/includes/resources/domains/_object.md.erb b/docs/v3/source/includes/resources/domains/_object.md.erb index 3637d4f0a4f..e4cc5c5fad2 100644 --- a/docs/v3/source/includes/resources/domains/_object.md.erb +++ b/docs/v3/source/includes/resources/domains/_object.md.erb @@ -17,6 +17,8 @@ Example Domain object | **internal** | _boolean_ | Whether the domain is used for internal (container-to-container) traffic | **router_group.guid** | _uuid_ | The guid of the desired router group to route `tcp` traffic through; if set, the domain will only be available for `tcp` traffic | **supported_protocols** | _list of strings_ | Available protocols for routes using the domain, currently `http` and `tcp` +| **enforce_route_policies** | _boolean_ | When `true`, GoRouter enforces route policies for routes on this domain. This field only appears in the response when set to `true`. **Immutable** after domain creation +| **route_policies_scope** | _string_ | Operator-defined boundary for allowed callers: `any`, `org`, or `space`. Required when `enforce_route_policies` is `true`. This field only appears when `enforce_route_policies` is `true`. **Immutable** after domain creation | **relationships.organization** | [_to-one relationship_](#to-one-relationships) | The organization the domain is scoped to; if set, the domain will only be available in that organization; otherwise, the domain will be globally available | **relationships.shared_organizations** | [_to-many relationship_](#to-many-relationships) | Organizations the domain is shared with; if set, the domain will be available in these organizations in addition to the organization the domain is scoped to | **metadata.labels** | [_label object_](#labels) | Labels applied to the domain diff --git a/docs/v3/source/includes/resources/route_policies/_create.md.erb b/docs/v3/source/includes/resources/route_policies/_create.md.erb new file mode 100644 index 00000000000..9a8e5465f66 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_create.md.erb @@ -0,0 +1,115 @@ +### Create a route policy + +``` +Example Request (Allow specific app) +``` + +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:app:d76446a1-f429-4444-8797-be2f78b75b08", + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + }, + "metadata": { + "labels": { "team": "frontend" }, + "annotations": { "description": "Allow frontend app to call backend API" } + } + }' +``` + +``` +Example Response +``` + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +<%= yield_content :single_route_policy, { + labels: { "team" => "frontend" }, + annotations: { "description" => "Allow frontend app to call backend API" } +} %> +``` + +``` +Example Request (Allow all apps in a space) +``` + +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:space:3fa85f64-5717-4562-b3fc-2c963f66afa6", + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + } + }' +``` + +``` +Example Request (Allow any caller) +``` + +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:any", + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + } + }' +``` + +#### Definition +`POST /v3/route_policies` + +#### Required parameters + +| Name | Type | Description +| ----------- | -------- | ----------- +| **source** | _string_ | The policy selector. Must be `cf:app:`, `cf:space:`, `cf:org:`, or `cf:any` +| **relationships.route** | [_to-one relationship_](#to-one-relationships) | The route this policy applies to + +#### Optional parameters + +| Name | Type | Description +| ----------- | -------- | ----------- +| **metadata.labels** | [_label object_](#labels) | Labels applied to the route policy +| **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the route policy + +#### Validation rules + +- The route's domain must have `enforce_route_policies` set to `true` +- The route's domain must not be internal (internal routes bypass GoRouter) +- The `source` must be unique per route (duplicate sources are rejected) +- If the route already has a `cf:any` policy, no other sources can be added +- If adding `cf:any`, the route must not have any existing policies +- The source GUID is not validated at creation time (allows cross-org sharing) + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Space Developer | Can create policies for routes in spaces they can write to diff --git a/docs/v3/source/includes/resources/route_policies/_delete.md.erb b/docs/v3/source/includes/resources/route_policies/_delete.md.erb new file mode 100644 index 00000000000..ab6eaa0eba0 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_delete.md.erb @@ -0,0 +1,31 @@ +### Delete a route policy + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" \ + -X DELETE \ + -H "Authorization: bearer [token]" +``` + +``` +Example Response +``` + +```http +HTTP/1.1 204 No Content +``` + +#### Definition +`DELETE /v3/route_policies/:guid` + +Deleting a route policy removes the access control for that specific source. If this was the only policy on the route, the route will become inaccessible (no callers will be allowed) until new policies are added or a `cf:any` policy is created. + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Space Developer | Can delete policies for routes in spaces they can write to diff --git a/docs/v3/source/includes/resources/route_policies/_get.md.erb b/docs/v3/source/includes/resources/route_policies/_get.md.erb new file mode 100644 index 00000000000..b274699a5f0 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_get.md.erb @@ -0,0 +1,40 @@ +### Get a route policy + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +``` +Example Response +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +<%= yield_content :single_route_policy %> +``` + +#### Definition +`GET /v3/route_policies/:guid` + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Admin Read-Only | +Global Auditor | +Org Manager | +Org Auditor | +Org Billing Manager | +Space Auditor | +Space Developer | +Space Manager | +Space Supporter | diff --git a/docs/v3/source/includes/resources/route_policies/_header.md b/docs/v3/source/includes/resources/route_policies/_header.md new file mode 100644 index 00000000000..7c18cfcaa5d --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_header.md @@ -0,0 +1,11 @@ +## Route Policies + +Route policies control which Cloud Foundry apps, spaces, or organizations can access routes on identity-aware domains. When a domain has `enforce_route_policies` enabled, GoRouter automatically enforces these access controls using mutual TLS (mTLS) to verify the identity of the calling application. + +Route policies are defined using a `source` selector that specifies who can access the route: +- `cf:app:` - Allow a specific app +- `cf:space:` - Allow all apps in a space +- `cf:org:` - Allow all apps in an organization +- `cf:any` - Allow any caller (cannot be combined with other sources on the same route) + +**Note:** Route policies can only be created for routes on domains where `enforce_route_policies` is `true` and the domain is not internal (internal routes use container-to-container networking and bypass GoRouter). diff --git a/docs/v3/source/includes/resources/route_policies/_list.md.erb b/docs/v3/source/includes/resources/route_policies/_list.md.erb new file mode 100644 index 00000000000..12242286ef1 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_list.md.erb @@ -0,0 +1,152 @@ +### List route policies + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/route_policies" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +``` +Example Response +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +<%= yield_content :paginated_list_of_route_policies, '/v3/route_policies' %> +``` + +``` +Example Request with Filters +``` + +```shell +curl "https://api.example.org/v3/route_policies?route_guids=89b32bd6-688f-4424-b94f-2e2c86495a5f&sources=cf:any" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +``` +Example Request with Include +``` + +```shell +curl "https://api.example.org/v3/route_policies?include=route,source" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +#### Definition +`GET /v3/route_policies` + +#### Query parameters + +| Name | Type | Description +| ----------- | -------- | ----------- +| **guids** | _list of strings_ | Comma-delimited list of route policy guids to filter by +| **route_guids** | _list of strings_ | Comma-delimited list of route guids to filter by +| **space_guids** | _list of strings_ | Comma-delimited list of space guids to filter by (filters by the route's space) +| **sources** | _list of strings_ | Comma-delimited list of exact source strings to filter by (e.g., `cf:any`, `cf:app:guid`) +| **source_guids** | _list of strings_ | Comma-delimited list of GUIDs to text-match against source strings (useful for finding stale policies when resources are deleted) +| **page** | _integer_ | Page to display; valid values are integers >= 1 +| **per_page** | _integer_ | Number of results per page; valid values are 1 through 5000 +| **order_by** | _string_ | Value to sort by. Defaults to ascending; prepend with `-` to sort descending. Valid values are `created_at`, `updated_at` +| **label_selector** | _string_ | A query string containing a list of [label selector](#labels-and-selectors) requirements +| **include** | _string_ | Optionally include related resources in the response; valid values are `route`, `app`, `space`, `organization`, and `source` (which includes app, space, or organization based on the source type) +| **created_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) +| **updated_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) + +#### Filtering examples + +- **Filter by route**: `GET /v3/route_policies?route_guids=89b32bd6-688f-4424-b94f-2e2c86495a5f` +- **Filter by space**: `GET /v3/route_policies?space_guids=3fa85f64-5717-4562-b3fc-2c963f66afa6` +- **Filter by source type**: `GET /v3/route_policies?sources=cf:any` +- **Find policies referencing a specific app**: `GET /v3/route_policies?source_guids=d76446a1-f429-4444-8797-be2f78b75b08` +- **Include source resources**: `GET /v3/route_policies?include=source` (batch-loads the app, space, or org referenced in each policy's source) +- **Include route and app**: `GET /v3/route_policies?include=route,app` + +#### Use cases + +**Allow a frontend app to call a backend API:** +```shell +# Create route policy +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:app:frontend-app-guid", + "relationships": { + "route": { + "data": { "guid": "backend-route-guid" } + } + } + }' +``` + +**Allow all apps in a space to access a shared service:** +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:space:development-space-guid", + "relationships": { + "route": { + "data": { "guid": "shared-service-route-guid" } + } + } + }' +``` + +**Open a route to any caller (public API):** +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:any", + "relationships": { + "route": { + "data": { "guid": "public-api-route-guid" } + } + } + }' +``` + +**Find all policies for routes in a specific space:** +```shell +curl "https://api.example.org/v3/route_policies?space_guids=my-space-guid" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +**Find stale policies referencing a deleted app:** +```shell +# After deleting an app, find policies that referenced it +curl "https://api.example.org/v3/route_policies?source_guids=deleted-app-guid&include=source" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Admin Read-Only | +Global Auditor | +Org Manager | +Org Auditor | +Org Billing Manager | +Space Auditor | +Space Developer | +Space Manager | +Space Supporter | diff --git a/docs/v3/source/includes/resources/route_policies/_object.md.erb b/docs/v3/source/includes/resources/route_policies/_object.md.erb new file mode 100644 index 00000000000..9b58b0fd6c2 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_object.md.erb @@ -0,0 +1,23 @@ + +### The route policy object + +``` +Example Route Policy object +``` +```json +<%= yield_content :single_route_policy %> +``` + +| Name | Type | Description +| -------------- | ------------------------ | ------------------------------------------------------ +| **guid** | _uuid_ | Unique identifier for the route policy +| **created_at** | _[timestamp](#timestamps)_ | The time with zone when the object was created +| **updated_at** | _[timestamp](#timestamps)_ | The time with zone when the object was last updated +| **source** | _string_ | The policy selector specifying who can access the route. Must be one of:
- `cf:app:` (specific app)
- `cf:space:` (all apps in a space)
- `cf:org:` (all apps in an organization)
- `cf:any` (any caller - cannot be combined with other sources) +| **relationships.route** | [_to-one relationship_](#to-one-relationships) | The route this policy applies to +| **relationships.app** | [_to-one relationship_](#to-one-relationships) | Read-only. The app referenced in the source (populated only when source is `cf:app:`) +| **relationships.space** | [_to-one relationship_](#to-one-relationships) | Read-only. The space referenced in the source (populated only when source is `cf:space:`) +| **relationships.organization** | [_to-one relationship_](#to-one-relationships) | Read-only. The organization referenced in the source (populated only when source is `cf:org:`) +| **metadata.labels** | [_label object_](#labels) | Labels applied to the route policy +| **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the route policy +| **links** | [_links object_](#links) | Links to related resources diff --git a/docs/v3/source/includes/resources/route_policies/_update.md.erb b/docs/v3/source/includes/resources/route_policies/_update.md.erb new file mode 100644 index 00000000000..eee0c2bace3 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_update.md.erb @@ -0,0 +1,51 @@ +### Update a route policy + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" \ + -X PATCH \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "metadata": { + "labels": { "team": "backend" }, + "annotations": { "note": "Updated contact info" } + } + }' +``` + +``` +Example Response +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +<%= yield_content :single_route_policy, { + labels: { "team" => "backend" }, + annotations: { "note" => "Updated contact info" } +} %> +``` + +#### Definition +`PATCH /v3/route_policies/:guid` + +#### Optional parameters + +| Name | Type | Description +| ----------- | -------- | ----------- +| **metadata.labels** | [_label object_](#labels) | Labels applied to the route policy +| **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the route policy + +**Note:** This endpoint only supports updating metadata (labels and annotations). The `source` and route relationship are immutable after creation. To change the source, delete the policy and create a new one. + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Space Developer | Can update policies for routes in spaces they can write to diff --git a/docs/v3/source/index.html.md b/docs/v3/source/index.html.md index beb313d063e..6fa2e570c52 100644 --- a/docs/v3/source/index.html.md +++ b/docs/v3/source/index.html.md @@ -25,6 +25,7 @@ includes: - api_resources/revisions - api_resources/roles - api_resources/root + - api_resources/route_policies - api_resources/routes - api_resources/security_groups - api_resources/service_brokers @@ -243,6 +244,13 @@ includes: - resources/root/header - resources/root/global_root - resources/root/v3_root + - resources/route_policies/header + - resources/route_policies/object + - resources/route_policies/create + - resources/route_policies/get + - resources/route_policies/list + - resources/route_policies/update + - resources/route_policies/delete - resources/routes/header - resources/routes/object - resources/routes/destination_object 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