Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions app/access/access_rule_access.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
module VCAP::CloudController
class AccessRuleAccess < BaseAccess
# Space Developer of the route's space can manage access rules.
# No bilateral requirement — destination-controlled auth only.

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

route = access_rule.route
return false unless route

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

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

route = access_rule.route
return false unless route

object_is_visible_to_user?(access_rule, context.user)
end

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

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

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

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

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

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

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

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

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

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

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

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

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

dataset = build_dataset(message)

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

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

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

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

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

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

route = VCAP::CloudController::Route.find(guid: message.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)

unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") unless route.domain.enforce_access_rules

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

# Uniqueness: name and selector must be unique per route
unprocessable!("An access rule with name '#{message.name}' already exists for this route.") if route.access_rules.any? { |r| r.name == message.name }
unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.") if existing_selectors.include?(message.selector)

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

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

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

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

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

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

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

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

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

access_rule.destroy
head :no_content
end

private

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

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

dataset = dataset.where(route_id: readable_route_ids)

if message.requested?(:route_guids)
dataset = dataset.
join(:routes, id: :route_id).
where(routes__guid: message.route_guids).
select_all(:route_access_rules)
end

if message.requested?(:space_guids)
dataset = dataset.
join(:routes, id: :route_id).
where(routes__space_id: VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)).
select_all(:route_access_rules)
end

dataset = dataset.where(name: message.names) if message.requested?(:names)
dataset = dataset.where(selector: message.selectors) if message.requested?(:selectors)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

hash
end

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

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

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

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

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

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

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

register_allowed_keys %i[
name
selector
relationships
]

validates_with NoAdditionalKeysValidator
validates_with RelationshipValidator

validates :name, presence: true, string: true
validates :selector, presence: true, string: true

validate :selector_format_valid
validate :selector_not_cf_any_with_others

delegate :route_guid, to: :relationships_message

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

private

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

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

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

class Relationships < BaseMessage
register_allowed_keys [:route]

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

def route_guid
HashUtils.dig(route, :data, :guid)
end
end
end
end
9 changes: 9 additions & 0 deletions app/messages/access_rule_update_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require 'messages/metadata_base_message'

module VCAP::CloudController
class AccessRuleUpdateMessage < MetadataBaseMessage
register_allowed_keys []

validates_with NoAdditionalKeysValidator
end
end
22 changes: 22 additions & 0 deletions app/messages/access_rules_list_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
require 'messages/list_message'

module VCAP::CloudController
class AccessRulesListMessage < ListMessage
register_allowed_keys %i[
route_guids
space_guids
names
selectors
include
]

validates_with NoAdditionalParamsValidator
validates_with IncludeParamValidator, valid_values: ['selector_resource', 'route']

validates :space_guids, array: true, allow_nil: true

def self.from_params(params)
super(params, %w[route_guids space_guids names selectors include])
end
end
end
Loading
Loading