rails-auth 1.3.0 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,5 +3,8 @@
3
3
  # Pull in core library components that work with any Rack application
4
4
  require "rails/auth/rack"
5
5
 
6
+ # Rails configuration builder
7
+ require "rails/auth/config_builder"
8
+
6
9
  # Rails controller method support
7
10
  require "rails/auth/controller_methods"
@@ -1,4 +1,4 @@
1
- # Pull in default predicate matchers
1
+ # Pull in default matchers
2
2
  require "rails/auth/acl/matchers/allow_all"
3
3
 
4
4
  module Rails
@@ -7,7 +7,7 @@ module Rails
7
7
  class ACL
8
8
  attr_reader :resources
9
9
 
10
- # Predicate matchers available by default in ACLs
10
+ # Matchers available by default in ACLs
11
11
  DEFAULT_MATCHERS = {
12
12
  allow_all: Matchers::AllowAll
13
13
  }.freeze
@@ -21,7 +21,7 @@ module Rails
21
21
  end
22
22
 
23
23
  # @param [Array<Hash>] :acl Access Control List configuration
24
- # @param [Hash] :matchers predicate matchers for use with this ACL
24
+ # @param [Hash] :matchers authorizers use with this ACL
25
25
  #
26
26
  def initialize(acl, matchers: {})
27
27
  raise TypeError, "expected Array for acl, got #{acl.class}" unless acl.is_a?(Array)
@@ -34,32 +34,37 @@ module Rails
34
34
  resources = entry["resources"]
35
35
  raise ParseError, "no 'resources' key present in entry: #{entry.inspect}" unless resources
36
36
 
37
- predicates = parse_predicates(entry, matchers.merge(DEFAULT_MATCHERS))
37
+ matcher_instances = parse_matchers(entry, matchers.merge(DEFAULT_MATCHERS))
38
38
 
39
39
  resources.each do |resource|
40
- @resources << Resource.new(resource, predicates).freeze
40
+ @resources << Resource.new(resource, matcher_instances).freeze
41
41
  end
42
42
  end
43
43
 
44
44
  @resources.freeze
45
45
  end
46
46
 
47
- # Match the Rack environment against the ACL, checking all predicates
47
+ # Match the Rack environment against the ACL, checking all matchers
48
48
  #
49
49
  # @param [Hash] :env Rack environment
50
50
  #
51
- # @return [Boolean] is the request authorized?
51
+ # @return [String, nil] name of the first matching matcher, or nil if unauthorized
52
52
  #
53
53
  def match(env)
54
- @resources.any? { |resource| resource.match(env) }
54
+ @resources.each do |resource|
55
+ matcher_name = resource.match(env)
56
+ return matcher_name if matcher_name
57
+ end
58
+
59
+ nil
55
60
  end
56
61
 
57
- # Find all resources that match the ACL. Predicates are *NOT* checked,
62
+ # Find all resources that match the ACL. Matchers are *NOT* checked,
58
63
  # instead only the initial checks for the "resources" section of the ACL
59
- # are performed. Use the `#match` method to validate predicates.
64
+ # are performed. Use the `#match` method to validate matchers.
60
65
  #
61
66
  # This method is intended for debugging AuthZ failures. It can find all
62
- # resources that match the given request so the corresponding predicates
67
+ # resources that match the given request so the corresponding matchers
63
68
  # can be introspected.
64
69
  #
65
70
  # @param [Hash] :env Rack environment
@@ -72,8 +77,8 @@ module Rails
72
77
 
73
78
  private
74
79
 
75
- def parse_predicates(entry, matchers)
76
- predicates = {}
80
+ def parse_matchers(entry, matchers)
81
+ matcher_instances = {}
77
82
 
78
83
  entry.each do |name, options|
79
84
  next if name == "resources"
@@ -82,10 +87,10 @@ module Rails
82
87
  raise ArgumentError, "no matcher for #{name}" unless matcher_class
83
88
  raise TypeError, "expected Class for #{name}" unless matcher_class.is_a?(Class)
84
89
 
85
- predicates[name.freeze] = matcher_class.new(options.freeze).freeze
90
+ matcher_instances[name.freeze] = matcher_class.new(options.freeze).freeze
86
91
  end
87
92
 
88
- predicates.freeze
93
+ matcher_instances.freeze
89
94
  end
90
95
  end
91
96
  end
@@ -1,7 +1,7 @@
1
1
  module Rails
2
2
  module Auth
3
3
  class ACL
4
- # Built-in predicate matchers
4
+ # Built-in matchers
5
5
  module Matchers
6
6
  # Allows unauthenticated clients to access to a given resource
7
7
  class AllowAll
@@ -22,7 +22,12 @@ module Rails
22
22
  end
23
23
 
24
24
  def call(env)
25
- raise NotAuthorizedError, "unauthorized request" unless Rails::Auth.authorized?(env) || @acl.match(env)
25
+ unless Rails::Auth.authorized?(env)
26
+ matcher_name = @acl.match(env)
27
+ raise NotAuthorizedError, "unauthorized request" unless matcher_name
28
+ Rails::Auth.set_allowed_by(env, "matcher:#{matcher_name}")
29
+ end
30
+
26
31
  @app.call(env)
27
32
  end
28
33
  end
@@ -5,7 +5,7 @@ module Rails
5
5
  class ACL
6
6
  # Rules for a particular route
7
7
  class Resource
8
- attr_reader :http_methods, :path, :host, :predicates
8
+ attr_reader :http_methods, :path, :host, :matchers
9
9
 
10
10
  # Valid HTTP methods
11
11
  HTTP_METHODS = %w(GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK).freeze
@@ -15,11 +15,11 @@ module Rails
15
15
 
16
16
  # @option :options [String] :method HTTP method allowed ("ALL" for all methods)
17
17
  # @option :options [String] :path path to the resource (regex syntax allowed)
18
- # @param [Hash] :predicates matchers for this resource
18
+ # @param [Hash] :matchers which matchers are used for this resource
19
19
  #
20
- def initialize(options, predicates)
21
- raise TypeError, "expected Hash for options" unless options.is_a?(Hash)
22
- raise TypeError, "expected Hash for predicates" unless predicates.is_a?(Hash)
20
+ def initialize(options, matchers)
21
+ raise TypeError, "expected Hash for options" unless options.is_a?(Hash)
22
+ raise TypeError, "expected Hash for matchers" unless matchers.is_a?(Hash)
23
23
 
24
24
  unless (extra_keys = options.keys - VALID_OPTIONS).empty?
25
25
  raise ParseError, "unrecognized key in ACL resource: #{extra_keys.first}"
@@ -30,7 +30,7 @@ module Rails
30
30
 
31
31
  @http_methods = extract_methods(methods)
32
32
  @path = /\A#{path}\z/
33
- @predicates = predicates.freeze
33
+ @matchers = matchers.freeze
34
34
 
35
35
  # Unlike method and path, host is optional
36
36
  host = options["host"]
@@ -38,19 +38,20 @@ module Rails
38
38
  end
39
39
 
40
40
  # Match this resource against the given Rack environment, checking all
41
- # predicates to ensure at least one of them matches
41
+ # matchers to ensure at least one of them matches
42
42
  #
43
43
  # @param [Hash] :env Rack environment
44
44
  #
45
- # @return [Boolean] resource and predicates match the given request
45
+ # @return [String, nil] name of the matcher which matched, or nil if none matched
46
46
  #
47
47
  def match(env)
48
- return false unless match!(env)
49
- @predicates.any? { |_name, predicate| predicate.match(env) }
48
+ return nil unless match!(env)
49
+ name, = @matchers.find { |_name, matcher| matcher.match(env) }
50
+ name
50
51
  end
51
52
 
52
53
  # Match *only* the request method/path/host against the given Rack environment.
53
- # Predicates are NOT checked.
54
+ # matchers are NOT checked.
54
55
  #
55
56
  # @param [Hash] :env Rack environment
56
57
  #
@@ -0,0 +1,83 @@
1
+ module Rails
2
+ module Auth
3
+ # Configures Rails::Auth middleware for use in a Rails application
4
+ module ConfigBuilder
5
+ extend self
6
+
7
+ # Application-level configuration (i.e. config/application.rb)
8
+ def application(config, acl_file: Rails.root.join("config/acl.yml"), matchers: {})
9
+ config.x.rails_auth.acl = Rails::Auth::ACL.from_yaml(
10
+ File.read(acl_file.to_s),
11
+ matchers: matchers
12
+ )
13
+
14
+ config.middleware.use Rails::Auth::ACL::Middleware, acl: config.x.acl
15
+ end
16
+
17
+ # Development configuration (i.e. config/environments/development.rb)
18
+ def development(config, development_credentials: {}, error_page: :debug)
19
+ error_page_middleware(config, error_page)
20
+ credential_injector_middleware(config, development_credentials) unless development_credentials.empty?
21
+ end
22
+
23
+ # Test configuration (i.e. config/environments/test.rb)
24
+ def test(config)
25
+ # Simulated credentials to be injected with InjectorMiddleware
26
+ credential_injector_middleware(config, config.x.rails_auth.test_credentials ||= {})
27
+ end
28
+
29
+ def production(
30
+ config,
31
+ cert_filters: nil,
32
+ require_cert: false,
33
+ ca_file: nil,
34
+ error_page: Rails.root.join("public/403.html"),
35
+ monitor: nil
36
+ )
37
+ raise ArgumentError, "no cert_filters given but require_cert is true" if require_cert && !cert_filters
38
+ raise ArgumentError, "no ca_file given but cert_filters were set" if cert_filters && !ca_file
39
+
40
+ error_page_middleware(config, error_page)
41
+
42
+ if cert_filters
43
+ config.middleware.insert_before Rails::Auth::ACL::Middleware,
44
+ Rails::Auth::X509::Middleware,
45
+ require_cert: require_cert,
46
+ cert_filters: cert_filters,
47
+ ca_file: ca_file,
48
+ logger: Rails.logger
49
+ end
50
+
51
+ return unless monitor
52
+ config.middleware.insert_before Rails::Auth::ACL::Middleware,
53
+ Rails::Auth::Monitor::Middleware,
54
+ monitor
55
+ end
56
+
57
+ private
58
+
59
+ # Adds error page middleware to the chain
60
+ def error_page_middleware(config, error_page)
61
+ case error_page
62
+ when :debug
63
+ config.middleware.insert_before Rails::Auth::ACL::Middleware,
64
+ Rails::Auth::ErrorPage::DebugMiddleware,
65
+ acl: config.x.rails_auth.acl
66
+ when Pathname, String
67
+ config.middleware.insert_before Rails::Auth::ACL::Middleware,
68
+ Rails::Auth::ErrorPage::Middleware,
69
+ page_body: Pathname(error_page).read
70
+ when FalseClass, NilClass
71
+ else raise TypeError, "bad error page mode: #{mode.inspect}"
72
+ end
73
+ end
74
+
75
+ # Adds Rails::Auth::Credentials::InjectorMiddleware to the chain with the given credentials
76
+ def credential_injector_middleware(config, credentials)
77
+ config.middleware.insert_before Rails::Auth::ACL::Middleware,
78
+ Rails::Auth::Credentials::InjectorMiddleware,
79
+ credentials
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,45 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "forwardable"
4
+
3
5
  module Rails
4
6
  # Modular resource-based authentication and authorization for Rails/Rack
5
7
  module Auth
6
- # Rack environment key for all rails-auth credentials
7
- CREDENTIALS_ENV_KEY = "rails-auth.credentials".freeze
8
-
9
- # Functionality for storing credentials in the Rack environment
10
- module Credentials
11
- # Obtain credentials from a Rack environment
12
- #
13
- # @param [Hash] :env Rack environment
14
- #
15
- def credentials(env)
16
- env.fetch(CREDENTIALS_ENV_KEY, {})
17
- end
18
-
19
- # Add a credential to the Rack environment
20
- #
21
- # @param [Hash] :env Rack environment
22
- # @param [String] :type credential type to add to the environment
23
- # @param [Object] :credential object to add to the environment
24
- #
25
- def add_credential(env, type, credential)
26
- credentials = env[CREDENTIALS_ENV_KEY] ||= {}
8
+ # Stores a set of credentials
9
+ class Credentials
10
+ extend Forwardable
27
11
 
28
- # Adding a credential is idempotent, so attempting to reregister
29
- # the same credential should be harmless
30
- return env if credentials.key?(type) && credentials[type] == credential
12
+ def_delegators :@credentials, :[], :fetch, :empty?, :key?, :to_hash
31
13
 
32
- # raise if we already have a cred, but it didn't short-circuit as
33
- # being == to the one supplied
34
- raise ArgumentError, "credential #{type} already added to request" if credentials.key?(type)
14
+ def self.from_rack_env(env)
15
+ new(env.fetch(Rails::Auth::Env::CREDENTIALS_ENV_KEY, {}))
16
+ end
35
17
 
36
- credentials[type] = credential
18
+ def initialize(credentials = {})
19
+ raise TypeError, "expected Hash, got #{credentials.class}" unless credentials.is_a?(Hash)
20
+ @credentials = credentials
21
+ end
37
22
 
38
- env
23
+ def []=(type, value)
24
+ raise TypeError, "expected String for type, got #{type.class}" unless type.is_a?(String)
25
+ raise AlreadyAuthorizedError, "credential '#{type}' has already been set" if @credentials.key?(type)
26
+ @credentials[type] = value
39
27
  end
40
28
  end
41
-
42
- # Include these functions in Rails::Auth for convenience
43
- extend Credentials
44
29
  end
45
30
  end
@@ -1,6 +1,6 @@
1
1
  module Rails
2
2
  module Auth
3
- module Credentials
3
+ class Credentials
4
4
  # A middleware for injecting an arbitrary credentials hash into the Rack environment
5
5
  # This is intended for development and testing purposes where you would like to
6
6
  # simulate a given X.509 certificate being used in a request or user logged in
@@ -11,7 +11,7 @@ module Rails
11
11
  end
12
12
 
13
13
  def call(env)
14
- env[Rails::Auth::CREDENTIALS_ENV_KEY] = @credentials
14
+ env[Rails::Auth::Env::CREDENTIALS_ENV_KEY] = @credentials
15
15
  @app.call(env)
16
16
  end
17
17
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Auth
5
+ # Wrapper for Rack environments with Rails::Auth helpers
6
+ class Env
7
+ # Rack environment key for marking external authorization
8
+ AUTHORIZED_ENV_KEY = "rails-auth.authorized".freeze
9
+
10
+ # Rack environment key for storing what allowed the request
11
+ ALLOWED_BY_ENV_KEY = "rails-auth.allowed-by".freeze
12
+
13
+ # Rack environment key for all rails-auth credentials
14
+ CREDENTIALS_ENV_KEY = "rails-auth.credentials".freeze
15
+
16
+ attr_reader :allowed_by, :credentials
17
+
18
+ # @param [Hash] :env Rack environment
19
+ def initialize(env, credentials: {}, authorized: false, allowed_by: nil)
20
+ raise TypeError, "expected Hash for credentials, got #{credentials.class}" unless credentials.is_a?(Hash)
21
+
22
+ @env = env
23
+ @credentials = Credentials.new(credentials.merge(@env.fetch(CREDENTIALS_ENV_KEY, {})))
24
+ @authorized = env.fetch(AUTHORIZED_ENV_KEY, authorized)
25
+ @allowed_by = env.fetch(ALLOWED_BY_ENV_KEY, allowed_by)
26
+ end
27
+
28
+ # Check whether a request has been authorized
29
+ def authorized?
30
+ @authorized
31
+ end
32
+
33
+ # Mark the environment as authorized to access the requested resource
34
+ #
35
+ # @param [String] :allowed_by label of what allowed the request
36
+ def authorize(allowed_by)
37
+ self.allowed_by = allowed_by
38
+ @authorized = true
39
+ end
40
+
41
+ # Set the name of the authority which authorized the request
42
+ #
43
+ # @param [String] :allowed_by label of what allowed the request
44
+ def allowed_by=(allowed_by)
45
+ raise AlreadyAuthorizedError, "already allowed by #{@allowed_by.ispect}" if @allowed_by
46
+ raise TypeError, "expected String for allowed_by, got #{allowed_by.class}" unless allowed_by.is_a?(String)
47
+ @allowed_by = allowed_by
48
+ end
49
+
50
+ # Return a Rack environment
51
+ #
52
+ # @return [Hash] Rack environment
53
+ def to_rack
54
+ credentials = @env[CREDENTIALS_ENV_KEY] ||= {}
55
+ credentials.merge!(@credentials.to_hash)
56
+
57
+ @env[AUTHORIZED_ENV_KEY] = @authorized if @authorized
58
+ @env[ALLOWED_BY_ENV_KEY] = @allowed_by if @allowed_by
59
+
60
+ @env
61
+ end
62
+ end
63
+ end
64
+ end
@@ -113,8 +113,8 @@
113
113
  <td class="label"><%= h((resource.http_methods || "ALL").join(" ")) %> <%= h(format_path(resource.path)) %></td>
114
114
  <td>
115
115
  <ul>
116
- <% resource.predicates.each do |name, predicate| %>
117
- <li><%= h(name) %>: <%= h(format_attributes(predicate)) %></li>
116
+ <% resource.matchers.each do |name, matcher| %>
117
+ <li><%= h(name) %>: <%= h(format_attributes(matcher)) %></li>
118
118
  <% end %>
119
119
  </ul>
120
120
  </td>
@@ -1,9 +1,15 @@
1
1
  module Rails
2
2
  module Auth
3
+ # Base class of all Rails::Auth errors
4
+ Error = Class.new(StandardError)
5
+
3
6
  # Unauthorized!
4
- NotAuthorizedError = Class.new(StandardError)
7
+ NotAuthorizedError = Class.new(Error)
5
8
 
6
9
  # Error parsing e.g. an ACL
7
- ParseError = Class.new(StandardError)
10
+ ParseError = Class.new(Error)
11
+
12
+ # Internal errors involving authorizing things that are already authorized
13
+ AlreadyAuthorizedError = Class.new(Error)
8
14
  end
9
15
  end
@@ -0,0 +1,64 @@
1
+ module Rails
2
+ # Modular resource-based authentication and authorization for Rails/Rack
3
+ module Auth
4
+ module_function
5
+
6
+ # Mark a request as externally authorized. Causes ACL checks to be skipped.
7
+ #
8
+ # @param [Hash] :rack_env Rack environment
9
+ # @param [String] :allowed_by what allowed the request
10
+ #
11
+ def authorized!(rack_env, allowed_by)
12
+ Env.new(rack_env).tap do |env|
13
+ env.authorize(allowed_by)
14
+ end.to_rack
15
+ end
16
+
17
+ # Check whether a request has been authorized
18
+ #
19
+ # @param [Hash] :rack_env Rack environment
20
+ #
21
+ def authorized?(rack_env)
22
+ Env.new(rack_env).authorized?
23
+ end
24
+
25
+ # Mark what authorized the request in the Rack environment
26
+ #
27
+ # @param [Hash] :env Rack environment
28
+ # @param [String] :allowed_by what allowed this request
29
+ def set_allowed_by(rack_env, allowed_by)
30
+ Env.new(rack_env).tap do |env|
31
+ env.allowed_by = allowed_by
32
+ end.to_rack
33
+ end
34
+
35
+ # Read what authorized the request
36
+ #
37
+ # @param [Hash] :rack_env Rack environment
38
+ #
39
+ # @return [String, nil] what authorized the request
40
+ def allowed_by(rack_env)
41
+ Env.new(rack_env).allowed_by
42
+ end
43
+
44
+ # Obtain credentials from a Rack environment
45
+ #
46
+ # @param [Hash] :rack_env Rack environment
47
+ #
48
+ def credentials(rack_env)
49
+ Credentials.from_rack_env(rack_env)
50
+ end
51
+
52
+ # Add a credential to the Rack environment
53
+ #
54
+ # @param [Hash] :rack_env Rack environment
55
+ # @param [String] :type credential type to add to the environment
56
+ # @param [Object] :credential object to add to the environment
57
+ #
58
+ def add_credential(rack_env, type, credential)
59
+ Env.new(rack_env).tap do |env|
60
+ env.credentials[type] = credential
61
+ end.to_rack
62
+ end
63
+ end
64
+ end