Skip to content

Commit 82d917b

Browse files
authored
RFC0055 Identity-Aware Routing (#4910)
* Add allowed_sources support for mTLS app-to-app routing - Add app_to_app_mtls_routing feature flag (default: false) - Add allowed_sources to RouteOptionsMessage with validation - Validate allowed_sources structure (apps/spaces/orgs arrays, any boolean) - Validate that app/space/org GUIDs exist in database - Enforce mutual exclusivity of 'any' with apps/spaces/orgs lists * Add unit tests for allowed_sources validation Tests cover: - Feature flag disabled: allowed_sources rejected as unknown field - Structure validation: object type, valid keys, array types, boolean any - any exclusivity: cannot combine any:true with apps/spaces/orgs lists - GUID existence validation: apps, spaces, orgs must exist in database - Combined options: allowed_sources works with loadbalancing * Fix allowed_sources validation to handle symbol keys Rails parses JSON with symbol keys, but validation was comparing against string keys. Add normalized_allowed_sources helper to transform keys to strings for consistent comparison. * Rename allowed_sources to mtls_allowed_sources for clarity Rename the route options field from allowed_sources to mtls_allowed_sources for better clarity about its purpose in mTLS app-to-app routing. Updates RouteOptionsMessage to use the new field name in: - Allowed keys registration - Feature flag gating - Validation methods - All related tests * Refactor mTLS route options to RFC-0027 compliant flat format Change from nested mtls_allowed_sources object to flat options: - mtls_allowed_apps: comma-separated app GUIDs (string) - mtls_allowed_spaces: comma-separated space GUIDs (string) - mtls_allowed_orgs: comma-separated org GUIDs (string) - mtls_allow_any: boolean (true/false) This complies with RFC-0027 which requires route options to only use numbers, strings, and boolean values (no nested objects or arrays). * Implement RFC domain-scoped mTLS routing with /v3/access_rules API Replace POC route-options-based mTLS implementation with RFC-compliant architecture: Domain model changes: - Add enforce_access_rules (boolean) and access_rules_scope (any/org/space) to domains - Fields are immutable after domain creation - Update DomainCreateMessage, DomainPresenter, and DomainCreate action Access Rules resource: - New /v3/access_rules API with full CRUD operations - RouteAccessRule model with guid, name, selector, route_id - Selector format: cf:app:<uuid>, cf:space:<uuid>, cf:org:<uuid>, or cf:any - Enforce cf:any exclusivity and per-route name/selector uniqueness - Space Developer can manage rules for routes in their space Diego sync path: - Inject access_scope and access_rules into route options for GoRouter - Filter internal mTLS keys (access_scope, access_rules) from public /v3/routes API - Add access_rules to eager load to avoid N+1 queries Tests: - Unit tests for AccessRuleCreateMessage (selector validation, cf:any rules) - Request specs for /v3/access_rules CRUD (create, show, list, delete, metadata update) - Updated domain_create_message_spec for enforce_access_rules validation - Updated routing_info_spec to verify mTLS options injection - Updated route_presenter_spec to verify internal keys are filtered Remove POC artifacts: - Remove app_to_app_mtls_routing feature flag - Remove mtls_allowed_* keys from route_options_message * Fix access_rules_controller permissions query - Replace non-existent readable_space_scoped_space_guids_query with proper subquery - Use readable_space_scoped_spaces_query for non-global readers - Handle global readers separately with all routes * Add automatic Diego sync callbacks to RouteAccessRule - Add after_create and after_destroy callbacks to touch associated processes - Updates process.updated_at to trigger Diego ProcessesSync immediately - Eliminates 30-second wait for access rule changes to propagate to GoRouter - Add comprehensive unit tests for callbacks and validations - Ensure RouteAccessRule model is loaded in app/models.rb This enables automatic synchronization of access rules to Diego/GoRouter within seconds instead of requiring manual app restarts or waiting for the next sync cycle. * Implement include=selector_resource for /v3/access_rules endpoint - Add include parameter support to AccessRulesListMessage - Refactor IncludeAccessRuleSelectorResourceDecorator to match RFC format: - Return separate arrays for apps, spaces, organizations instead of selector_resources - Include full resource details using appropriate presenters - Batch resource fetching by type with eager loading - Auto-deduplicate resources - Gracefully handle stale/missing resources - Wire up decorator to AccessRulesController - Add comprehensive request specs for include=selector_resource Fixes: uninitialized constant error by adding proper require statement * Add space_guids filtering to /v3/access_rules endpoint Implement space-based filtering for access rules endpoint to enable querying all access rules within a given space using ?space_guids=<guid> query parameter. Changes: - Add space_guids to AccessRulesListMessage with array validation - Implement space filtering in AccessRulesController#build_dataset - Add comprehensive unit tests for AccessRulesListMessage - Add request specs for single/multiple space filtering and combinations - Follow existing CAPI patterns for space_guids filtering The filter joins through the routes table to filter access rules by the space_id of their associated routes. * Implement include=route for /v3/access_rules endpoint Add support for including route resources when listing access rules via the ?include=route query parameter. Changes: - Create IncludeAccessRuleRouteDecorator to handle route inclusion - Wire up decorator in AccessRulesController - Add comprehensive request specs for include=route - Test single/multiple routes, uniqueness, and combining with selector_resource - Follow existing CAPI decorator patterns for resource inclusion The decorator fetches and presents Route resources referenced by the access rules, adding them to the 'included' section of the response. * Remove name field from access rules, add read-only relationships per RFC updates Update /v3/access_rules API to align with latest RFC changes: - Remove 'name' field from RouteAccessRule model and API - Add database migration to drop name column and unique index - Use labels/annotations for metadata instead of name field - Add read-only relationships (app, space, organization) to responses extracted from selector (cf:app:X, cf:space:X, cf:org:X, cf:any) - Replace 'names' filter with 'guids' filter - Add 'selector_resource_guids' filter for text-match against selectors - Update include support: add individual app, space, organization (in addition to existing selector_resource and route) - Remove name-based uniqueness validation (keep selector uniqueness) - Update all tests to remove name references Breaking changes: - POST /v3/access_rules no longer accepts 'name' field - GET /v3/access_rules responses no longer include 'name' field - Filter parameter 'names' removed, use 'guids' instead - Access rule responses now include app/space/organization relationships * Add metadata support to RouteAccessRule model - Create RouteAccessRuleLabelModel and RouteAccessRuleAnnotationModel - Add one_to_many relationships for labels and annotations to RouteAccessRule - Add database migrations for route_access_rule_labels and route_access_rule_annotations tables - Fixes: undefined method 'labels' error in AccessRulePresenter This enables metadata (labels/annotations) support for access rules, required by the RFC changes that removed the 'name' field in favor of using labels/annotations for metadata storage. * Fix class loading for RouteAccessRule metadata models Add require statements for RouteAccessRuleLabelModel and RouteAccessRuleAnnotationModel to app/models.rb. Rails autoloading is disabled for app/** so all models must be explicitly required. This fixes the error: uninitialized constant VCAP::CloudController::RouteAccessRuleLabelModel * Add validation to prevent access rules on internal domains per RFC Per RFC requirement (line 246-247): Access rules cannot be created for routes on internal domains (domains created with --internal). Internal routes use container-to-container networking and bypass GoRouter entirely, so GoRouter cannot enforce access rules. Changes: - Add validation in AccessRulesController#create to reject access rules on internal domains with 422 status - Add test coverage for internal domain validation - Error message explains why: internal domains bypass GoRouter * Consolidate access rules migrations, fix RuboCop offenses, and clean up tests - Collapse 4 migrations into 1 consolidated migration (20260407100001) that creates route_access_rules, route_access_rule_labels, and route_access_rule_annotations tables - Remove name field from access rules per RFC updates - Fix all RuboCop offenses: Style/CollectionQuerying (.count > 0 -> .any?), Migration/AddConstraintName (primary_key :id, name: :id), Metrics/BlockLength, Metrics/CyclomaticComplexity, and others - Add stale resource detection in presenter (null data for deleted resources) - Extract controller methods to reduce complexity - Use relative class names within VCAP::CloudController module - Fix test shadowing of rack-test app method (let(:app) -> let(:frontend_app)) - Fix Sequel validation assertion style (.include(:presence) not strings) * Fix race condition, double join, LIKE injection, N+1 queries, and domain API surface in access rules - Wrap create action in transaction with FOR UPDATE lock to prevent concurrent inserts from violating cf:any exclusivity constraints - Rescue Sequel::UniqueConstraintViolation to return 422 instead of 500 - Join routes table at most once when both route_guids and space_guids filters are requested, preventing ambiguous column references - Escape LIKE metacharacters (% and _) in selector_resource_guids filter - Replace deprecated routes__column syntax with Sequel[:routes][:column] - Remove per-row DB existence checks in AccessRulePresenter to eliminate N+1 queries; relationship GUIDs are now included directly from selector - Only include enforce_access_rules and access_rules_scope in domain responses when enforce_access_rules is true * Fix incomplete LIKE metacharacter escaping (CodeQL rb/incomplete-sanitization) Escape backslash characters before % and _ in selector_resource_guids LIKE filtering to prevent backslash-based injection. * Add tests for LIKE metacharacter escaping (backslash, underscore) Expand selector_resource_guids filtering tests to cover all three LIKE metacharacters: %, _, and backslash. * Fix MySQL key length limit in metadata table migration Annotations table used key VARCHAR(1000) with a unique index on (resource_guid, key), totaling 5020 bytes in utf8mb4 — exceeding MySQL's 3072-byte max key length. Align with codebase convention established in migration 20240102150000: use key_name VARCHAR(63) with a three-column unique index on (resource_guid, key_prefix, key_name). Also add NOT NULL default '' to key_prefix on both labels and annotations tables. * Fix routing_info_spec: remove nonexistent name field from RouteAccessRule RouteAccessRule does not have a name column. The test was passing name: to create(), triggering Sequel::MassAssignmentRestriction. * Fix route presenter regression: include options: {} when empty The INTERNAL_ROUTE_OPTIONS filtering change incorrectly suppressed the options key entirely when public_options was empty. The original behavior includes options whenever route.options is not nil, even when the hash is empty. * Fix CI failures: route presenter options logic and domain V2 serialization Route presenter: preserve original behavior where options: {} is included for routes with empty options, but omit options when all keys are internal-only (access_scope, access_rules). Domain model: remove enforce_access_rules and access_rules_scope from V2 export/import_attributes — these are V3-only fields exposed through the domain presenter, not the legacy V2 API. * Rebrand: access rules → route policies, selector → source Complete terminology shift for identity-aware routing RFC: - Access Rules → Route Policies (API, models, tables) - Selector → Source (field names, query params) - Domain fields: enforce_access_rules → enforce_route_policies - Domain fields: access_rules_scope → route_policies_scope - Route options: access_scope → route_policy_scope - Route options: access_rules → route_policy_sources Aligns with existing CF 'network policies' terminology and C2C network policy convention (source → destination). Database migrations replaced (no production DBs affected). 67 files changed, 602 insertions(+), 600 deletions(-) * Fix test failures: complete terminology rebrand in specs - Fix require statement in route_policy_create_message_spec.rb - Update all test references from old to new terminology: * selectors → sources * selector_resource_guids → source_guids * selector_resource → source (in include parameters) * enforce_access_rules → enforce_route_policies * access_rules_scope → route_policies_scope * route_access_rules → route_policies (table name) - Fix Rubocop indentation in route_policies_list_message_spec.rb Addresses CI/CD test failures in PR #4910. * Fix routing_info_spec: use enforce_route_policies field names * Fix domain_create_message_spec: use enforce_route_policies field names * Fix route_policies_spec: use 'Source' terminology and 'sources' query param - Line 206: expect error message to include 'Source' (not 'Selector') - Line 292: use query param ?sources= (not ?selectors=) - Line 344: use query param ?sources= in combined filter test These complete the terminology rebrand from 'selector' to 'source' in the route policies context per RFC be8d74c1. * Revert: restore label_selector (was incorrectly renamed to label_source) During the selector→source rebrand for route policies, we accidentally renamed 'label_selector' to 'label_source' across the test suite. This was WRONG because: - label_selector is a standard Cloud Controller query parameter for filtering resources by labels/annotations (e.g., ?label_selector=env=prod) - It is used across many endpoints: apps, packages, orgs, buildpacks, etc. - It has nothing to do with route policies or the terminology rebrand - The overly broad sed command caught it by mistake This commit restores label_selector to its correct name, fixing 13+ test failures caused by the regression. * Add API documentation for Route Policies - Add comprehensive Route Policies API documentation - Create, get, list, update, delete endpoints - Complete filtering documentation (guids, route_guids, space_guids, sources, source_guids) - Include parameter documentation (route, app, space, organization, source) - Practical use case examples for common scenarios - Validation rules and permission matrices - Update Domains documentation - Add enforce_route_policies and route_policies_scope fields - Document immutability of enforcement settings - Add example for creating identity-aware domain - Update index.html.md to include route_policies resources Route policies enable identity-aware routing by controlling which Cloud Foundry apps, spaces, or organizations can access routes on domains with enforce_route_policies enabled. GoRouter enforces these access controls using mutual TLS (mTLS). * Add include support to route policies show endpoint Address review feedback: the show action now supports the same include parameters (source, route) as the list endpoint, allowing clients to sideload related resources in a single request. * Remove devbox configuration files from branch These files are for local development only and should not be part of the upstream PR. * Address philippthun's review feedback - Remove unused BaseAccess subclass (no v2 endpoint) - Remove without_guid_generation on association (int PK) - Use ProcessRouteHandler.notify_backend_of_route_update in db.after_commit instead of touching process updated_at - Rewrite migration using VCAP::Migration.common for consistent table layout (Timestamp with defaults, proper indexes) - Extract RoutePolicyCreate action from controller - Remove manual guid/created_at/updated_at assignment in create - Reuse find_and_authorize_route in update/destroy - Pass locked policies to validate_source_exclusivity - Remove unnecessary 'source != cf:any' check - Fix comment order to match code * Rename add_mtls_options to add_route_policy_options and refactor structure Adopt philippthun's suggested pattern: build a fresh hash and merge at the end instead of duplicating info['options']. Also rename the method and variable from mtls_ prefix to route_policy_ to align with current terminology. * Limit route policy includes to 'route' and 'source' only Remove 'app', 'space', and 'organization' as separate include values. The 'source' include already resolves the underlying app/space/org from the source string (e.g. cf:app:<guid>), making the individual keywords redundant and confusing — especially since space_guids filters by route space while include=space would include the source space. * Prevent enforce_route_policies on internal and router_group domains Internal domains bypass GoRouter entirely (using container-to-container networking), so GoRouter cannot enforce route policies. Similarly, TCP router groups do not support mTLS policy enforcement. Add validation to DomainCreateMessage#mutually_exclusive_fields to reject these invalid combinations at domain creation time. * Fix RuboCop offenses in domain_create_message and route_policies_list_message_spec * Add include=route_policies support on routes endpoints Add IncludeRoutePoliciesDecorator to allow consumers to include route_policies when fetching routes via GET /v3/routes and GET /v3/routes/:guid with ?include=route_policies. The decorator batch-loads all route policies for the routes in the result set to avoid N+1 queries. * Fix race condition in RoutePolicyCreate: lock parent route row SELECT ... FOR UPDATE on an empty route_policies table acquires no row locks. Two concurrent creates on the same route_id both read [] and both pass cf:any exclusivity validation, leaving the route with cf:any and cf:app:<guid> policies simultaneously. Lock the parent Route row instead — it always exists, so concurrent transactions serialize at the database level regardless of how many policies currently exist. Add spec/unit/actions/route_policy_create_spec.rb to cover the action and document the locking behaviour. * Fix missing permission filter in IncludeRoutePolicySourceDecorator Without a permission check, a space developer could write a route policy with a source GUID from another org and receive the full AppPresenter / SpacePresenter / OrganizationPresenter payload via ?include=source. Follow the IncludeSpaceDecorator pattern: build a Permissions queryer from SecurityContext.current_user and gate each fetch on readable_space_guids_query (for apps and spaces) and readable_org_guids_query (for orgs). Global readers skip the filter. Add spec/unit/decorators/include_route_policy_source_decorator_spec.rb. * Enforce route_policies_scope boundary in RoutePolicyCreate Reject cf:org:<guid> sources on domains with route_policies_scope: 'space'. cf:any is allowed at all scopes — GoRouter contextualizes it to the domain boundary at request time (e.g. 'any app in this space' on a space-scoped domain). * Fix org manager visibility in route policies index Switch readable_space_scoped_spaces_query to readable_spaces_query in build_dataset. The former uses SPACE_ROLES which excludes ORG_MANAGER; the latter uses ROLES_FOR_SPACE_READING which includes it, consistent with how show gates access via can_read_from_space?. Add permissions for list/show endpoint shared examples covering all roles to spec/request/route_policies_spec.rb. * Split route_policies.source into source_type + source_guid columns Store source_type ('app'|'space'|'org'|'any') and source_guid (UUID or '' for cf:any) as separate columns. Add virtual source getter/setter on RoutePolicy so all existing callers remain unchanged. Use empty string instead of NULL for source_guid to preserve DB unique constraint on (route_id, source_type, source_guid). * Guard RoutePolicy source= setter against nil and malformed input Use source_guid.to_s.empty? in getter to avoid NoMethodError when source_guid is nil in test contexts. Move describe 'columns' block to top level. * Add unit tests for RoutePolicy source virtual getter/setter Covers all four source types for getter, all four types for setter, plus nil-input guard and malformed-input guard. * Add column-level assertions to RoutePolicyCreate spec * Update route policy filters to use source_type/source_guid columns sources filter: parse composite string and apply two-column WHERE. source_guids filter: exact match on source_guid column (no more LIKE). * Use source_type/source_guid directly in IncludeRoutePolicySourceDecorator Eliminates the SOURCE_REGEX constant and match/parse pattern. * Use source_type/source_guid directly in RoutePolicyPresenter Remove regex match from build_relationships. Add presenter unit spec covering all four source types (app, space, org, any). * Fix RuboCop offenses * Grant org auditors read access to route policies Route policies are policies on routes; org auditors can already read routes via ROLES_FOR_ROUTE_READING. Use the same role set for route policy reads so the permission model is consistent. Org billing managers remain unable to read route policies (consistent with routes). Adds can_read_route_policy_from_space? and readable_route_policies_spaces_query to Permissions. * Restore .envrc to main branch version * Add audit events for route policy create/update/delete Records audit.route_policy.create, .update, and .delete events via RoutePolicyEventRepository, consistent with the audit.route.* pattern used by routes. * Add label_selector support to route policies list - RoutePoliciesListMessage now inherits from MetadataListMessage, which auto-registers :label_selector and wires LabelSelectorRequirementValidator - build_dataset in RoutePoliciesController delegates to LabelSelectorQueryGenerator when a selector is requested * Fix RoutePolicy.create calls using non-hex fake GUIDs via virtual source= setter The source= setter parses 'cf:app:GUID' using [0-9a-f-]+ regex (hex UUIDs only). Fake GUIDs like 'app-guid-1' contain non-hex chars so the regex fails to match, leaving source_type nil and failing the presence validation. Fix by calling .create with source_type:/source_guid: directly, bypassing the virtual setter in the 6 affected test fixtures. * Register route_policy audit events in event_types_spec and docs * Validate sources format in RoutePoliciesListMessage Move SOURCE_REGEX to RoutePolicy model — natural home for a format constant shared across messages. Update RoutePolicyCreateMessage to reference RoutePolicy::SOURCE_REGEX. Add sources_format_valid validator to RoutePoliciesListMessage that checks each element against RoutePolicy::SOURCE_REGEX, along with validates :sources, array: true, allow_nil: true. Add tests covering nil, all four valid formats, fully invalid, and mixed valid/invalid arrays. * Guard against route deletion race in RoutePolicyCreate If the route is deleted between the controller fetch and the transaction lock, Route.for_update.first returns nil. Add an explicit guard to raise Error with a clear message rather than letting RoutePolicy.create fail with a foreign key violation. Add a test covering the deleted-route scenario using the same route-relation spy pattern as the concurrent-creates test. * Drop empty source_not_cf_any_with_others method Remove the registered validator and its empty method body. Move the explanatory comment inline next to validate :source_format_valid. * Fix source_guids filter description in route policies list docs The old description referenced text-matching against the full source string. Now that source is stored as separate source_type + source_guid columns, source_guids filters by exact match on the GUID portion only. * Fix destroy action: destroy before recording audit event Swap order so route_policy.destroy is called before record_route_policy_delete, ensuring no audit event is recorded if the destroy fails. Add request spec to verify no event is recorded when destroy raises. * Use can_read_route_policy_from_space? in find_and_authorize_route_for_policy Consistent with the show action (line 40) which already uses the dedicated permission check rather than the generic can_read_from_space?. * Use add_unique_constraint for labels/annotations unique indexes Switch from inline index(..., unique: true) to alter_table + add_unique_constraint following the convention established in 20240102150000_add_annotation_label_uniqueness, with names route_policy_labels_unique and route_policy_annotations_unique. * Mark route policies feature as experimental in docs
1 parent da9ddd9 commit 82d917b

61 files changed

Lines changed: 3808 additions & 18 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/actions/domain_create.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def create(message:, shared_organizations: [])
2121
end
2222

2323
domain.router_group_guid = message.router_group_guid
24+
domain.enforce_route_policies = message.enforce_route_policies || false
25+
domain.route_policies_scope = message.route_policies_scope
2426

2527
Domain.db.transaction do
2628
domain.save

‎app/actions/route_policy_create.rb‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
module VCAP::CloudController
2+
class RoutePolicyCreate
3+
class Error < StandardError
4+
end
5+
6+
def create(route:, message:)
7+
validate_scope!(route.domain, message.source)
8+
9+
RoutePolicy.db.transaction do
10+
# Lock the parent route row to serialize concurrent creates.
11+
# SELECT ... FOR UPDATE on an empty policies table acquires no row locks,
12+
# so two concurrent transactions can both read [] and both pass cf:any
13+
# exclusivity validation. Locking the route row (which always exists)
14+
# ensures they serialize regardless of how many policies currently exist.
15+
Route.where(id: route.id).for_update.first or raise Error.new("Route '#{route.guid}' not found.")
16+
17+
existing_policies = RoutePolicy.where(route_id: route.id).all
18+
validate_source_exclusivity(existing_policies, message.source)
19+
20+
RoutePolicy.create(
21+
source: message.source,
22+
route_id: route.id
23+
)
24+
end
25+
rescue Sequel::UniqueConstraintViolation
26+
raise Error.new("A route policy with source '#{message.source}' already exists for this route.")
27+
end
28+
29+
private
30+
31+
def validate_scope!(domain, source)
32+
return unless domain.route_policies_scope == 'space'
33+
return unless source.start_with?('cf:org:')
34+
35+
raise Error.new("Source '#{source}' is not allowed: domain's route_policies_scope is 'space'.")
36+
end
37+
38+
def validate_source_exclusivity(locked_policies, source)
39+
existing_sources = locked_policies.map(&:source)
40+
41+
# Enforce cf:any exclusivity: if new policy is cf:any, reject if route already has any policies;
42+
# if route already has a cf:any policy, reject new policies.
43+
raise Error.new("Cannot add 'cf:any' source when other route policies already exist for this route.") if source == 'cf:any' && existing_sources.any?
44+
raise Error.new("Cannot add source '#{source}': route already has a 'cf:any' policy.") if existing_sources.include?('cf:any')
45+
end
46+
end
47+
end
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
require 'messages/route_policy_create_message'
2+
require 'messages/route_policy_update_message'
3+
require 'messages/route_policies_list_message'
4+
require 'messages/route_policy_show_message'
5+
require 'presenters/v3/route_policy_presenter'
6+
require 'decorators/include_route_policy_source_decorator'
7+
require 'decorators/include_route_policy_route_decorator'
8+
require 'actions/route_policy_create'
9+
require 'repositories/route_policy_event_repository'
10+
require 'fetchers/label_selector_query_generator'
11+
12+
class RoutePoliciesController < ApplicationController
13+
def index
14+
message = RoutePoliciesListMessage.from_params(query_params)
15+
invalid_param!(message.errors.full_messages) unless message.valid?
16+
17+
dataset = build_dataset(message)
18+
19+
decorators = []
20+
decorators << IncludeRoutePolicySourceDecorator if IncludeRoutePolicySourceDecorator.match?(message.include)
21+
decorators << IncludeRoutePolicyRouteDecorator if IncludeRoutePolicyRouteDecorator.match?(message.include)
22+
23+
render status: :ok, json: Presenters::V3::PaginatedListPresenter.new(
24+
presenter: Presenters::V3::RoutePolicyPresenter,
25+
paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)),
26+
path: '/v3/route_policies',
27+
message: message,
28+
decorators: decorators
29+
)
30+
end
31+
32+
def show
33+
message = RoutePolicyShowMessage.from_params(query_params)
34+
unprocessable!(message.errors.full_messages) unless message.valid?
35+
36+
route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid])
37+
resource_not_found!(:route_policy) unless route_policy
38+
39+
route = route_policy.route
40+
resource_not_found!(:route_policy) unless route && permission_queryer.can_read_route_policy_from_space?(route.space.id, route.space.organization_id)
41+
42+
decorators = []
43+
decorators << IncludeRoutePolicySourceDecorator if IncludeRoutePolicySourceDecorator.match?(message.include)
44+
decorators << IncludeRoutePolicyRouteDecorator if IncludeRoutePolicyRouteDecorator.match?(message.include)
45+
46+
render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy, decorators: decorators)
47+
end
48+
49+
def create
50+
message = RoutePolicyCreateMessage.new(hashed_params[:body])
51+
unprocessable!(message.errors.full_messages) unless message.valid?
52+
53+
route = find_and_authorize_route(message.route_guid)
54+
validate_route_domain(route)
55+
56+
route_policy = VCAP::CloudController::RoutePolicyCreate.new.create(route: route, message: message)
57+
58+
route_policy_event_repository.record_route_policy_create(
59+
route_policy,
60+
user_audit_info,
61+
{ 'source' => message.source, 'route_guid' => message.route_guid }
62+
)
63+
64+
render status: :created, json: Presenters::V3::RoutePolicyPresenter.new(route_policy)
65+
rescue VCAP::CloudController::RoutePolicyCreate::Error => e
66+
unprocessable!(e.message)
67+
end
68+
69+
def update
70+
route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid])
71+
resource_not_found!(:route_policy) unless route_policy
72+
73+
find_and_authorize_route_for_policy(route_policy)
74+
75+
message = RoutePolicyUpdateMessage.new(hashed_params[:body])
76+
unprocessable!(message.errors.full_messages) unless message.valid?
77+
78+
VCAP::CloudController::MetadataUpdate.update(route_policy, message)
79+
80+
route_policy_event_repository.record_route_policy_update(
81+
route_policy.reload,
82+
user_audit_info,
83+
message.audit_hash
84+
)
85+
86+
render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy.reload)
87+
end
88+
89+
def destroy
90+
route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid])
91+
resource_not_found!(:route_policy) unless route_policy
92+
93+
find_and_authorize_route_for_policy(route_policy)
94+
95+
route_policy.destroy
96+
97+
route_policy_event_repository.record_route_policy_delete(
98+
route_policy,
99+
user_audit_info
100+
)
101+
head :no_content
102+
end
103+
104+
private
105+
106+
def route_policy_event_repository
107+
@route_policy_event_repository ||= Repositories::RoutePolicyEventRepository.new
108+
end
109+
110+
def find_and_authorize_route(route_guid)
111+
route = VCAP::CloudController::Route.find(guid: route_guid)
112+
resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
113+
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
114+
suspended! unless permission_queryer.is_space_active?(route.space.id)
115+
route
116+
end
117+
118+
def find_and_authorize_route_for_policy(route_policy)
119+
route = route_policy.route
120+
resource_not_found!(:route_policy) unless route && permission_queryer.can_read_route_policy_from_space?(route.space.id, route.space.organization_id)
121+
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
122+
suspended! unless permission_queryer.is_space_active?(route.space.id)
123+
end
124+
125+
def validate_route_domain(route)
126+
if route.domain.internal?
127+
unprocessable!('Cannot create route policies for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.')
128+
end
129+
return if route.domain.enforce_route_policies
130+
131+
unprocessable!("Cannot create route policies for route '#{route.guid}': the route's domain does not have enforce_route_policies enabled.")
132+
end
133+
134+
def build_dataset(message)
135+
dataset = VCAP::CloudController::RoutePolicy.dataset
136+
137+
if permission_queryer.can_read_globally?
138+
readable_route_ids = VCAP::CloudController::Route.select(:id)
139+
else
140+
readable_space_ids = permission_queryer.readable_route_policies_spaces_query.select(:id)
141+
readable_route_ids = VCAP::CloudController::Route.where(space_id: readable_space_ids).select(:id)
142+
end
143+
144+
dataset = dataset.where(route_id: readable_route_ids)
145+
146+
# Join routes at most once when either route_guids or space_guids is requested
147+
if message.requested?(:route_guids) || message.requested?(:space_guids)
148+
dataset = dataset.
149+
join(:routes, id: :route_id).
150+
select_all(:route_policies)
151+
152+
dataset = dataset.where(Sequel[:routes][:guid] => message.route_guids) if message.requested?(:route_guids)
153+
154+
dataset = dataset.where(Sequel[:routes][:space_id] => VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)) if message.requested?(:space_guids)
155+
end
156+
157+
dataset = dataset.where(guid: message.guids) if message.requested?(:guids)
158+
159+
if message.requested?(:sources)
160+
conditions = message.sources.map do |src|
161+
if src == 'cf:any'
162+
Sequel.&(source_type: 'any', source_guid: '')
163+
else
164+
m = src.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/)
165+
Sequel.&(source_type: m[1], source_guid: m[2])
166+
end
167+
end
168+
dataset = dataset.where(Sequel.|(*conditions))
169+
end
170+
171+
dataset = dataset.where(source_guid: message.source_guids) if message.requested?(:source_guids)
172+
173+
if message.requested?(:label_selector)
174+
dataset = VCAP::CloudController::LabelSelectorQueryGenerator.add_selector_queries(
175+
label_klass: VCAP::CloudController::RoutePolicyLabelModel,
176+
resource_dataset: dataset,
177+
requirements: message.requirements,
178+
resource_klass: VCAP::CloudController::RoutePolicy
179+
)
180+
end
181+
182+
dataset
183+
end
184+
end

‎app/controllers/v3/routes_controller.rb‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require 'messages/route_update_destinations_message'
99
require 'actions/update_route_destinations'
1010
require 'decorators/include_route_domain_decorator'
11+
require 'decorators/include_route_policies_decorator'
1112
require 'presenters/v3/route_presenter'
1213
require 'presenters/v3/route_destinations_presenter'
1314
require 'presenters/v3/paginated_list_presenter'
@@ -45,6 +46,7 @@ def index
4546
decorators << IncludeRouteDomainDecorator if IncludeRouteDomainDecorator.match?(message.include)
4647
decorators << IncludeSpaceDecorator if IncludeSpaceDecorator.match?(message.include)
4748
decorators << IncludeOrganizationDecorator if IncludeOrganizationDecorator.match?(message.include)
49+
decorators << IncludeRoutePoliciesDecorator if IncludeRoutePoliciesDecorator.match?(message.include)
4850

4951
render status: :ok, json: Presenters::V3::PaginatedListPresenter.new(
5052
presenter: Presenters::V3::RoutePresenter,
@@ -63,6 +65,7 @@ def show
6365
decorators << IncludeRouteDomainDecorator if IncludeRouteDomainDecorator.match?(message.include)
6466
decorators << IncludeSpaceDecorator if IncludeSpaceDecorator.match?(message.include)
6567
decorators << IncludeOrganizationDecorator if IncludeOrganizationDecorator.match?(message.include)
68+
decorators << IncludeRoutePoliciesDecorator if IncludeRoutePoliciesDecorator.match?(message.include)
6669

6770
render status: :ok, json: Presenters::V3::RoutePresenter.new(
6871
route,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module VCAP::CloudController
2+
class IncludeRoutePoliciesDecorator
3+
class << self
4+
def match?(include)
5+
include&.any? { |i| %w[route_policies].include?(i) }
6+
end
7+
8+
def decorate(hash, routes)
9+
hash[:included] ||= {}
10+
route_ids = routes.map(&:id).uniq
11+
route_policies = RoutePolicy.where(route_id: route_ids).
12+
eager(:route, :labels, :annotations).all
13+
14+
hash[:included][:route_policies] = route_policies.map { |rp| Presenters::V3::RoutePolicyPresenter.new(rp).to_hash }
15+
hash
16+
end
17+
end
18+
end
19+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
module VCAP::CloudController
2+
class IncludeRoutePolicyRouteDecorator
3+
# Handles `?include=route` for GET /v3/route_policies
4+
# Includes the route resources associated with the route policies
5+
6+
def self.match?(include_params)
7+
include_params&.include?('route')
8+
end
9+
10+
def self.decorate(hash, route_policies)
11+
hash[:included] ||= {}
12+
13+
# Collect all unique route IDs from route policies
14+
route_ids = route_policies.map(&:route_id).uniq
15+
16+
# Fetch routes with their associations
17+
routes = Route.where(id: route_ids).
18+
order(:created_at, :guid).
19+
eager(Presenters::V3::RoutePresenter.associated_resources).all
20+
21+
# Present routes
22+
hash[:included][:routes] = routes.map { |route| Presenters::V3::RoutePresenter.new(route).to_hash }
23+
24+
hash
25+
end
26+
end
27+
end
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
module VCAP::CloudController
2+
class IncludeRoutePolicySourceDecorator
3+
# Handles `?include=source` for GET /v3/route_policies
4+
# Stale/missing resources (source GUIDs that no longer exist) are silently absent.
5+
# Resources the current user cannot read are also silently absent.
6+
7+
def self.match?(include_params)
8+
return false unless include_params
9+
10+
include_params.include?('source')
11+
end
12+
13+
def self.decorate(hash, route_policies)
14+
hash[:included] ||= {}
15+
16+
# Collect all GUIDs by type
17+
app_guids = []
18+
space_guids = []
19+
org_guids = []
20+
21+
route_policies.each do |policy|
22+
next if policy.source_type == 'any'
23+
24+
case policy.source_type
25+
when 'app' then app_guids << policy.source_guid
26+
when 'space' then space_guids << policy.source_guid
27+
when 'org' then org_guids << policy.source_guid
28+
end
29+
end
30+
31+
permission_queryer = Permissions.new(SecurityContext.current_user)
32+
33+
# Fetch and present resources, filtering by what the current user can read
34+
hash[:included][:apps] = fetch_and_present_apps(app_guids.uniq, permission_queryer)
35+
hash[:included][:spaces] = fetch_and_present_spaces(space_guids.uniq, permission_queryer)
36+
hash[:included][:organizations] = fetch_and_present_organizations(org_guids.uniq, permission_queryer)
37+
38+
hash
39+
end
40+
41+
private_class_method def self.fetch_and_present_apps(guids, permission_queryer)
42+
return [] if guids.empty?
43+
44+
apps = AppModel.where(guid: guids)
45+
apps = apps.where(space_guid: permission_queryer.readable_space_guids_query) unless permission_queryer.can_read_globally?
46+
apps.order(:created_at, :guid).
47+
eager(Presenters::V3::AppPresenter.associated_resources).all.
48+
map { |app| Presenters::V3::AppPresenter.new(app).to_hash }
49+
end
50+
51+
private_class_method def self.fetch_and_present_spaces(guids, permission_queryer)
52+
return [] if guids.empty?
53+
54+
spaces = Space.where(guid: guids)
55+
spaces = spaces.where(guid: permission_queryer.readable_space_guids_query) unless permission_queryer.can_read_globally?
56+
spaces.order(:created_at, :guid).
57+
eager(Presenters::V3::SpacePresenter.associated_resources).all.
58+
map { |space| Presenters::V3::SpacePresenter.new(space).to_hash }
59+
end
60+
61+
private_class_method def self.fetch_and_present_organizations(guids, permission_queryer)
62+
return [] if guids.empty?
63+
64+
orgs = Organization.where(guid: guids)
65+
orgs = orgs.where(guid: permission_queryer.readable_org_guids_query) unless permission_queryer.can_read_globally?
66+
orgs.order(:created_at, :guid).
67+
eager(Presenters::V3::OrganizationPresenter.associated_resources).all.
68+
map { |org| Presenters::V3::OrganizationPresenter.new(org).to_hash }
69+
end
70+
end
71+
end

0 commit comments

Comments
 (0)