Skip to content
Open
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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ config/master.key
config/oauth2.yml
config/paperclip.yml
config/payments.yml
config/permissions_policy.yml
config/plan_rules.yml
config/redhat_customer_portal.yml
config/redis.yml
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ C:\\nppdf32Log\\debuglog.txt
/config/oauth2.yml
/config/paperclip.yml
/config/payments.yml
/config/permissions_policy.yml
/config/plan_rules.yml
/config/redhat_customer_portal.yml
/config/redis.yml
Expand Down
10 changes: 10 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,16 @@ def cache_store_config
config.three_scale.cors.enabled = false
config.three_scale.cors.merge!(try_config_for(:cors) || {})

config.three_scale.permissions_policy = ActiveSupport::OrderedOptions.new
config.three_scale.permissions_policy.merge!(try_config_for(:permissions_policy) || {})

# Convert nested portal configurations to OrderedOptions for permissions_policy
[:admin_portal, :developer_portal].each do |portal|
portal_config = ActiveSupport::OrderedOptions.new
portal_config.merge!(config.three_scale.permissions_policy.send(portal) || {})
config.three_scale.permissions_policy.send("#{portal}=", portal_config)
end

three_scale = config_for(:settings)

three_scale[:error_reporting_stages] = three_scale[:error_reporting_stages].to_s.split(/\W+/)
Expand Down
44 changes: 44 additions & 0 deletions config/examples/permissions_policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Permissions-Policy (formerly Feature-Policy) configuration
# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy
#
# Available directives (Rails 7.1):
# accelerometer, ambient_light_sensor, autoplay, camera, encrypted_media,
# fullscreen, geolocation, gyroscope, hid, idle_detection, magnetometer,
# microphone, midi, payment, picture_in_picture, screen_wake_lock,
# serial, sync_xhr, usb, web_share
#
# Each directive accepts an array of allowed origins:
# - "'self'" - Allow the feature for the current origin
# - "'none'" - Disallow the feature entirely
# - "https://example.com" - Allow for a specific origin

base: &default
# Admin portal policy - restrictive by default
admin_portal:
enabled: true
policy:
camera: ["'none'"]
microphone: ["'none'"]
geolocation: ["'none'"]
usb: ["'none'"]
payment: ["'none'"]
fullscreen: ["'self'"]

# Developer portal policy - permissive by default since we don't know what customers will publish
# When policy is empty, no Permissions-Policy header is set (browser defaults apply)
developer_portal:
enabled: true
policy: {}

development:
<<: *default

test:
<<: *default

production:
<<: *default
admin_portal:
enabled: false
developer_portal:
enabled: false
32 changes: 19 additions & 13 deletions config/initializers/permissions_policy.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
# Be sure to restart your server when you modify this file.

# Define an application-wide HTTP permissions policy. For further
# information see: https://developers.google.com/web/updates/2018/06/feature-policy

# Rails.application.config.permissions_policy do |policy|
# policy.camera :none
# policy.gyroscope :none
# policy.microphone :none
# policy.usb :none
# policy.fullscreen :self
# policy.payment :self, "https://secure.example.com"
# end
# frozen_string_literal: true

# Configure Permissions-Policy headers (formerly Feature-Policy)
# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy

require 'three_scale/permissions_policy'

Rails.application.configure do
if ThreeScale::PermissionsPolicy::AdminPortal.enabled?
policy_config = ThreeScale::PermissionsPolicy::AdminPortal.policy_config

if policy_config.present?
config.permissions_policy do |policy|
ThreeScale::PermissionsPolicy::AdminPortal.add_policy_config(policy, policy_config)
end
end
end
# When disabled, no Permissions-Policy header is set (permissive by default)
end
7 changes: 7 additions & 0 deletions lib/developer_portal/lib/developer_portal/engine.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
# frozen_string_literal: true

require 'three_scale/middleware/developer_portal_permissions_policy'

module DeveloperPortal
class Engine < ::Rails::Engine
isolate_namespace DeveloperPortal

config.autoload_paths += %W(#{config.root.join('lib')})
config.paths.add 'lib', eager_load: true

# Apply Developer Portal specific Permissions-Policy
config.middleware.use ThreeScale::Middleware::DeveloperPortalPermissionsPolicy

initializer :assets do |config|
Rails.application.config.assets.precompile += %w{ stats.css }
Rails.application.config.assets.precompile += %w{ stats.js }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module ThreeScale
module Middleware
class DeveloperPortalPermissionsPolicy

attr_reader :permissions_policy_header_value

def initialize(app)
@app = app

# Pre-compute the Permissions-Policy header once at startup
@permissions_policy_header_value = compute_permissions_policy_header
end

def call(env)
if permissions_policy_header_value.blank?
request = ActionDispatch::Request.new(env)
request.permissions_policy = nil
return @app.call(env)
end

_status, headers, _body = response = @app.call(env)

# Only apply if we have a pre-computed header
headers[ActionDispatch::Constants::FEATURE_POLICY] = permissions_policy_header_value

response
end

private

def compute_permissions_policy_header
policy_config = ThreeScale::PermissionsPolicy::DeveloperPortal.policy_config

# When disabled or no policy configured, don't set any header (permissive by default)
return nil unless ThreeScale::PermissionsPolicy::DeveloperPortal.enabled? && policy_config.present?

# Build the policy once at initialization
policy = ThreeScale::PermissionsPolicy::DeveloperPortal.build_policy(policy_config)
header_value = policy.build

# Don't set an empty header
return nil if header_value.blank?

header_value
end
end
end
end
68 changes: 68 additions & 0 deletions lib/three_scale/permissions_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

module ThreeScale
module PermissionsPolicy
class Base

class << self
def config
@config ||= Rails.configuration.three_scale.permissions_policy
end

def enabled?
raise NoMethodError, "#{__method__} not implemented in #{self.class}"
end

def policy_config
raise NoMethodError, "#{__method__} not implemented in #{self.class}"
end

# Builds an ActionDispatch::PermissionsPolicy object from a policy configuration hash
def build_policy(policy_config)
ActionDispatch::PermissionsPolicy.new do |policy|
add_policy_config(policy, policy_config)
end
end

# Applies a policy configuration hash to an existing policy object
def add_policy_config(policy, policy_config)
policy_config.each do |directive, values|
method_name = directive.to_s
next unless policy.respond_to?(method_name)

# Handle directives with sources (arrays) vs boolean directives
if values.is_a?(Array)
policy.public_send(method_name, *values)
else
policy.public_send(method_name, values)
end
end
end
end
end

class AdminPortal < Base
class << self
def enabled?
config&.admin_portal&.enabled == true
end

def policy_config
config&.admin_portal&.policy || {}
end
end
end

class DeveloperPortal < Base
class << self
def enabled?
config&.developer_portal&.enabled == true
end

def policy_config
config&.developer_portal&.policy || {}
end
end
end
end
end
59 changes: 59 additions & 0 deletions test/integration/developer_portal/permissions_policy_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

require 'test_helper'

class DeveloperPortal::PermissionsPolicyTest < ActionDispatch::IntegrationTest

class TestController < ApplicationController
def html
render html: '<html><body>Test</body></html>'.html_safe
end
end

def with_test_routes
DeveloperPortal::Engine.routes.draw do
get '/test/permissions-policy/html' => 'permissions_policy_test/test#html'
end
yield
ensure
Rails.application.routes_reloader.reload!
end

def setup
@provider = FactoryBot.create(:provider_account)
@buyer = FactoryBot.create(:buyer_account, provider_account: @provider)
host! @provider.internal_domain
end

test 'includes Permissions-Policy header when developer portal policy is not empty' do
policy_config = {
camera: ["'none'"],
microphone: ["'none'"],
geolocation: ["'none'"]
}
policy = ThreeScale::PermissionsPolicy::DeveloperPortal.build_policy(policy_config)
ThreeScale::Middleware::DeveloperPortalPermissionsPolicy.any_instance.stubs(permissions_policy_header_value: policy.build)

with_test_routes do
get '/test/permissions-policy/html'

permissions_header = response.headers['Feature-Policy']

# Verify it contains the permissive default_src directive from developer_portal_policy
assert_includes permissions_header, "camera 'none'"
assert_includes permissions_header, "microphone 'none'"
assert_includes permissions_header, "geolocation 'none'"
end
end

test 'does not include Permissions-Policy header when developer portal policy is empty' do
# By default, developer portal has empty policy (permissive)
with_test_routes do
get '/test/permissions-policy/html'

assert_response :success
# Empty policy should not set any header
assert_nil response.headers['Feature-Policy']
end
end
end
Loading