Skip to content

Commit df1ac2f

Browse files
committed
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
1 parent 9747046 commit df1ac2f

26 files changed

Lines changed: 1260 additions & 312 deletions

app/access/access_rule_access.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
module VCAP::CloudController
2+
class AccessRuleAccess < BaseAccess
3+
# Space Developer of the route's space can manage access rules.
4+
# No bilateral requirement — destination-controlled auth only.
5+
6+
def create?(access_rule, _params=nil)
7+
return true if admin_user?
8+
9+
route = access_rule.route
10+
return false unless route
11+
12+
space = route.space
13+
context.user_email && context.user.is_a?(User) &&
14+
space.developers.include?(context.user)
15+
end
16+
17+
def read?(access_rule)
18+
return true if admin_user? || admin_read_only_user? || global_auditor?
19+
20+
route = access_rule.route
21+
return false unless route
22+
23+
object_is_visible_to_user?(access_rule, context.user)
24+
end
25+
26+
def update?(access_rule, _params=nil)
27+
create?(access_rule)
28+
end
29+
30+
def delete?(access_rule)
31+
create?(access_rule)
32+
end
33+
34+
def index?(_object_class, _params=nil)
35+
admin_user? || admin_read_only_user? || has_read_scope? || global_auditor?
36+
end
37+
38+
def read_with_token?(_)
39+
admin_user? || admin_read_only_user? || has_read_scope? || global_auditor?
40+
end
41+
42+
def create_with_token?(_)
43+
admin_user? || has_write_scope?
44+
end
45+
46+
def read_for_update_with_token?(_)
47+
admin_user? || has_write_scope?
48+
end
49+
50+
def can_remove_related_object_with_token?(*args)
51+
read_for_update_with_token?(*args)
52+
end
53+
54+
def read_related_object_for_update_with_token?(*args)
55+
read_for_update_with_token?(*args)
56+
end
57+
58+
def update_with_token?(_)
59+
admin_user? || has_write_scope?
60+
end
61+
62+
def delete_with_token?(_)
63+
admin_user? || has_write_scope?
64+
end
65+
end
66+
end

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_access_rules = message.enforce_access_rules || false
25+
domain.access_rules_scope = message.access_rules_scope
2426

2527
Domain.db.transaction do
2628
domain.save
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
require 'messages/access_rule_create_message'
2+
require 'messages/access_rule_update_message'
3+
require 'messages/access_rules_list_message'
4+
require 'presenters/v3/access_rule_presenter'
5+
6+
class AccessRulesController < ApplicationController
7+
def index
8+
message = AccessRulesListMessage.from_params(query_params)
9+
invalid_param!(message.errors.full_messages) unless message.valid?
10+
11+
dataset = build_dataset(message)
12+
13+
render status: :ok, json: Presenters::V3::PaginatedListPresenter.new(
14+
presenter: Presenters::V3::AccessRulePresenter,
15+
paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)),
16+
path: '/v3/access_rules',
17+
message: message
18+
)
19+
end
20+
21+
def show
22+
access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid])
23+
resource_not_found!(:access_rule) unless access_rule
24+
25+
route = access_rule.route
26+
resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
27+
28+
render status: :ok, json: Presenters::V3::AccessRulePresenter.new(access_rule)
29+
end
30+
31+
def create
32+
message = AccessRuleCreateMessage.new(hashed_params[:body])
33+
unprocessable!(message.errors.full_messages) unless message.valid?
34+
35+
route = VCAP::CloudController::Route.find(guid: message.route_guid)
36+
resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
37+
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
38+
suspended! unless permission_queryer.is_space_active?(route.space.id)
39+
40+
unless route.domain.enforce_access_rules
41+
unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.")
42+
end
43+
44+
# Enforce cf:any exclusivity: if route already has a cf:any rule, reject new rules;
45+
# if new rule is cf:any, reject if route already has any rules.
46+
existing_selectors = route.access_rules.map(&:selector)
47+
if message.selector == 'cf:any' && existing_selectors.any?
48+
unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.")
49+
end
50+
if existing_selectors.include?('cf:any') && message.selector != 'cf:any'
51+
unprocessable!("Cannot add selector '#{message.selector}': route already has a 'cf:any' rule.")
52+
end
53+
54+
# Uniqueness: name and selector must be unique per route
55+
if route.access_rules.any? { |r| r.name == message.name }
56+
unprocessable!("An access rule with name '#{message.name}' already exists for this route.")
57+
end
58+
if existing_selectors.include?(message.selector)
59+
unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.")
60+
end
61+
62+
access_rule = VCAP::CloudController::RouteAccessRule.new(
63+
guid: SecureRandom.uuid,
64+
name: message.name,
65+
selector: message.selector,
66+
route_id: route.id,
67+
created_at: Time.now.utc,
68+
updated_at: Time.now.utc
69+
)
70+
access_rule.save
71+
72+
render status: :created, json: Presenters::V3::AccessRulePresenter.new(access_rule)
73+
end
74+
75+
def update
76+
access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid])
77+
resource_not_found!(:access_rule) unless access_rule
78+
79+
route = access_rule.route
80+
resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
81+
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
82+
suspended! unless permission_queryer.is_space_active?(route.space.id)
83+
84+
message = AccessRuleUpdateMessage.new(hashed_params[:body])
85+
unprocessable!(message.errors.full_messages) unless message.valid?
86+
87+
VCAP::CloudController::MetadataUpdate.update(access_rule, message)
88+
89+
render status: :ok, json: Presenters::V3::AccessRulePresenter.new(access_rule.reload)
90+
end
91+
92+
def destroy
93+
access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid])
94+
resource_not_found!(:access_rule) unless access_rule
95+
96+
route = access_rule.route
97+
resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
98+
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
99+
suspended! unless permission_queryer.is_space_active?(route.space.id)
100+
101+
access_rule.destroy
102+
head :no_content
103+
end
104+
105+
private
106+
107+
def build_dataset(message)
108+
dataset = VCAP::CloudController::RouteAccessRule.dataset
109+
110+
readable_route_ids = VCAP::CloudController::Route.
111+
join(:spaces, id: :space_id).
112+
where(Sequel.lit(permission_queryer.readable_space_scoped_space_guids_query)).
113+
select(:routes__id)
114+
115+
dataset = dataset.where(route_id: readable_route_ids)
116+
117+
if message.requested?(:route_guids)
118+
dataset = dataset.
119+
join(:routes, id: :route_id).
120+
where(routes__guid: message.route_guids).
121+
select_all(:route_access_rules)
122+
end
123+
124+
dataset = dataset.where(name: message.names) if message.requested?(:names)
125+
dataset = dataset.where(selector: message.selectors) if message.requested?(:selectors)
126+
127+
dataset
128+
end
129+
end
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
module VCAP::CloudController
2+
class IncludeAccessRuleSelectorResourceDecorator
3+
# Handles `?include=selector_resource` for GET /v3/access_rules
4+
# Stale/missing resources (selector GUIDs that no longer exist) are silently absent.
5+
6+
SELECTOR_REGEX = /\Acf:(app|space|org):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\z/
7+
8+
def self.match?(include_params)
9+
include_params&.include?('selector_resource')
10+
end
11+
12+
def self.decorate(hash, access_rules)
13+
included = []
14+
15+
access_rules.each do |rule|
16+
match = SELECTOR_REGEX.match(rule.selector)
17+
next unless match
18+
19+
resource_type = match[1]
20+
resource_guid = match[2]
21+
22+
resource = case resource_type
23+
when 'app'
24+
VCAP::CloudController::AppModel.find(guid: resource_guid)
25+
when 'space'
26+
VCAP::CloudController::Space.find(guid: resource_guid)
27+
when 'org'
28+
VCAP::CloudController::Organization.find(guid: resource_guid)
29+
end
30+
31+
next if resource.nil?
32+
33+
included << { type: resource_type, guid: resource.guid }
34+
end
35+
36+
hash[:included] = { selector_resources: included }
37+
hash
38+
end
39+
end
40+
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
require 'messages/metadata_base_message'
2+
3+
module VCAP::CloudController
4+
class AccessRuleCreateMessage < MetadataBaseMessage
5+
SELECTOR_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/
6+
7+
register_allowed_keys %i[
8+
name
9+
selector
10+
relationships
11+
]
12+
13+
validates_with NoAdditionalKeysValidator
14+
validates_with RelationshipValidator
15+
16+
validates :name, presence: true, string: true
17+
validates :selector, presence: true, string: true
18+
19+
validate :selector_format_valid
20+
validate :selector_not_cf_any_with_others
21+
22+
delegate :route_guid, to: :relationships_message
23+
24+
def relationships_message
25+
@relationships_message ||= Relationships.new(relationships&.deep_symbolize_keys)
26+
end
27+
28+
private
29+
30+
def selector_format_valid
31+
return unless selector.is_a?(String)
32+
return if SELECTOR_REGEX.match?(selector)
33+
34+
errors.add(:selector, "must be in format 'cf:app:<uuid>', 'cf:space:<uuid>', 'cf:org:<uuid>', or 'cf:any'")
35+
end
36+
37+
def selector_not_cf_any_with_others
38+
# enforced at the controller level when checking existing rules on the route
39+
end
40+
41+
class Relationships < BaseMessage
42+
register_allowed_keys [:route]
43+
44+
validates_with NoAdditionalKeysValidator
45+
validates :route, presence: true, to_one_relationship: true
46+
47+
def route_guid
48+
HashUtils.dig(route, :data, :guid)
49+
end
50+
end
51+
end
52+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require 'messages/metadata_base_message'
2+
3+
module VCAP::CloudController
4+
class AccessRuleUpdateMessage < MetadataBaseMessage
5+
register_allowed_keys []
6+
7+
validates_with NoAdditionalKeysValidator
8+
end
9+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
require 'messages/list_message'
2+
3+
module VCAP::CloudController
4+
class AccessRulesListMessage < ListMessage
5+
register_allowed_keys %i[
6+
route_guids
7+
names
8+
selectors
9+
]
10+
11+
validates_with NoAdditionalParamsValidator
12+
13+
def self.from_params(params)
14+
super(params, %w[route_guids names selectors])
15+
end
16+
end
17+
end

app/messages/domain_create_message.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class DomainCreateMessage < MetadataBaseMessage
1616
internal
1717
relationships
1818
router_group
19+
enforce_access_rules
20+
access_rules_scope
1921
]
2022

2123
def self.relationships_requested?
@@ -59,6 +61,12 @@ def self.relationships_requested?
5961
allow_nil: true,
6062
boolean: true
6163

64+
validates :enforce_access_rules,
65+
allow_nil: true,
66+
boolean: true
67+
68+
validate :access_rules_scope_validation
69+
6270
delegate :organization_guid, to: :relationships_message
6371
delegate :shared_organizations_guids, to: :relationships_message
6472

@@ -97,6 +105,20 @@ def router_group_validation
97105
errors.add(:router_group, 'guid must be a string') unless router_group_guid.is_a?(String)
98106
end
99107

108+
def access_rules_scope_validation
109+
if requested?(:access_rules_scope)
110+
unless access_rules_scope.nil? || %w[any org space].include?(access_rules_scope)
111+
errors.add(:access_rules_scope, "must be one of 'any', 'org', 'space'")
112+
end
113+
end
114+
115+
if requested?(:enforce_access_rules) && enforce_access_rules == true
116+
if !requested?(:access_rules_scope) || access_rules_scope.nil?
117+
errors.add(:access_rules_scope, 'is required when enforce_access_rules is true')
118+
end
119+
end
120+
end
121+
100122
class Relationships < BaseMessage
101123
def self.shared_organizations_requested?
102124
@shared_organizations_requested ||= proc { |a| a.requested?(:shared_organizations) }

0 commit comments

Comments
 (0)