rails-auth 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +18 -0
  4. data/.travis.yml +9 -1
  5. data/CONDUCT.md +5 -0
  6. data/CONTRIBUTING.md +14 -0
  7. data/Gemfile +11 -2
  8. data/Guardfile +5 -0
  9. data/LICENSE +202 -0
  10. data/README.md +267 -7
  11. data/Rakefile +3 -1
  12. data/lib/rails/auth.rb +3 -6
  13. data/lib/rails/auth/acl.rb +88 -0
  14. data/lib/rails/auth/acl/matchers/allow_all.rb +23 -0
  15. data/lib/rails/auth/acl/middleware.rb +31 -0
  16. data/lib/rails/auth/acl/resource.rb +74 -0
  17. data/lib/rails/auth/exceptions.rb +9 -0
  18. data/lib/rails/auth/principals.rb +36 -0
  19. data/lib/rails/auth/rack.rb +19 -0
  20. data/lib/rails/auth/rspec.rb +6 -0
  21. data/lib/rails/auth/rspec/helper_methods.rb +51 -0
  22. data/lib/rails/auth/rspec/matchers/acl_matchers.rb +13 -0
  23. data/lib/rails/auth/version.rb +4 -1
  24. data/lib/rails/auth/x509/filter/java.rb +25 -0
  25. data/lib/rails/auth/x509/filter/pem.rb +14 -0
  26. data/lib/rails/auth/x509/matcher.rb +22 -0
  27. data/lib/rails/auth/x509/middleware.rb +78 -0
  28. data/lib/rails/auth/x509/principal.rb +41 -0
  29. data/rails-auth.gemspec +20 -9
  30. data/spec/fixtures/example_acl.yml +27 -0
  31. data/spec/rails/auth/acl/matchers/allow_all_spec.rb +32 -0
  32. data/spec/rails/auth/acl/middleware_spec.rb +24 -0
  33. data/spec/rails/auth/acl/resource_spec.rb +105 -0
  34. data/spec/rails/auth/acl_spec.rb +28 -0
  35. data/spec/rails/auth/principals_spec.rb +36 -0
  36. data/spec/rails/auth/rspec/helper_methods_spec.rb +42 -0
  37. data/spec/rails/auth/rspec/matchers/acl_matchers_spec.rb +20 -0
  38. data/spec/rails/auth/x509/matcher_spec.rb +21 -0
  39. data/spec/rails/auth/x509/middleware_spec.rb +74 -0
  40. data/spec/rails/auth/x509/principal_spec.rb +27 -0
  41. data/spec/rails/auth_spec.rb +5 -0
  42. data/spec/spec_helper.rb +23 -0
  43. data/spec/support/claims_predicate.rb +11 -0
  44. data/spec/support/create_certs.rb +59 -0
  45. metadata +60 -24
  46. data/bin/console +0 -14
  47. data/bin/setup +0 -8
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
3
4
 
4
5
  RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
5
7
 
6
- task :default => :spec
8
+ task default: %w(spec rubocop)
@@ -1,7 +1,4 @@
1
- require "rails/auth/version"
1
+ # frozen_string_literal: true
2
2
 
3
- module Rails
4
- module Auth
5
- # Your code goes here...
6
- end
7
- end
3
+ # Pull in core library components that work with any Rack application
4
+ require "rails/auth/rack"
@@ -0,0 +1,88 @@
1
+ module Rails
2
+ module Auth
3
+ # Route-based access control lists
4
+ class ACL
5
+ # Predicate matchers available by default in ACLs
6
+ # These are added by the individual files in lib/rails/auth/acl/matchers
7
+ # at the time they're loaded.
8
+ DEFAULT_MATCHERS = {} # rubocop:disable Style/MutableConstant
9
+
10
+ # Pull in default predicate matchers
11
+ require "rails/auth/acl/matchers/allow_all"
12
+
13
+ DEFAULT_MATCHERS.freeze
14
+
15
+ # Create a Rails::Auth::ACL from a YAML representation of an ACL
16
+ #
17
+ # @param [String] :yaml serialized YAML to load an ACL from
18
+ def self.from_yaml(yaml, **args)
19
+ require "yaml"
20
+ new(YAML.load(yaml), **args)
21
+ end
22
+
23
+ # @param [Array<Hash>] :acl Access Control List configuration
24
+ # @param [Hash] :matchers predicate matchers for use with this ACL
25
+ #
26
+ def initialize(acl, matchers: {})
27
+ @resources = []
28
+
29
+ acl.each_with_index do |entry|
30
+ resources = entry["resources"]
31
+ fail ParseError, "no 'resources' key present in entry: #{entry.inspect}" unless resources
32
+
33
+ predicates = parse_predicates(entry, matchers.merge(DEFAULT_MATCHERS))
34
+
35
+ resources.each do |resource|
36
+ @resources << Resource.new(resource, predicates).freeze
37
+ end
38
+ end
39
+
40
+ @resources.freeze
41
+ end
42
+
43
+ # Match the Rack environment against the ACL, checking all predicates
44
+ #
45
+ # @param [Hash] :env Rack environment
46
+ #
47
+ # @return [Boolean] is the request authorized?
48
+ #
49
+ def match(env)
50
+ @resources.any? { |resource| resource.match(env) }
51
+ end
52
+
53
+ # Find all resources that match the ACL. Predicates are *NOT* checked,
54
+ # instead only the initial checks for the "resources" section of the ACL
55
+ # are performed. Use the `#match` method to validate predicates.
56
+ #
57
+ # This method is intended for debugging AuthZ failures. It can find all
58
+ # resources that match the given request so the corresponding predicates
59
+ # can be introspected.
60
+ #
61
+ # @param [Hash] :env Rack environment
62
+ #
63
+ # @return [Array<Rails::Auth::ACL::Resource>] matching resources
64
+ #
65
+ def matching_resources(env)
66
+ @resources.find_all { |resource| resource.match_method_and_path(env) }
67
+ end
68
+
69
+ private
70
+
71
+ def parse_predicates(entry, matchers)
72
+ predicates = {}
73
+
74
+ entry.each do |name, options|
75
+ next if name == "resources"
76
+
77
+ matcher_class = matchers[name.to_sym]
78
+ fail ArgumentError, "no matcher for #{name}" unless matcher_class
79
+ fail TypeError, "expected Class for #{name}" unless matcher_class.is_a?(Class)
80
+
81
+ predicates[name.freeze] = matcher_class.new(options.freeze).freeze
82
+ end
83
+
84
+ predicates.freeze
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,23 @@
1
+ module Rails
2
+ module Auth
3
+ class ACL
4
+ # Built-in predicate matchers
5
+ module Matchers
6
+ # Allows all principals access to a given resource
7
+ class AllowAll
8
+ def initialize(enabled)
9
+ fail ArgumentError, "enabled must be true/false" unless [true, false].include?(enabled)
10
+ @enabled = enabled
11
+ end
12
+
13
+ def match(_env)
14
+ @enabled
15
+ end
16
+ end
17
+
18
+ # Make `allow_all` available by default as an ACL matcher
19
+ ACL::DEFAULT_MATCHERS[:allow_all] = AllowAll
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ module Rails
2
+ module Auth
3
+ class ACL
4
+ # Authorizes requests by matching them against the given ACL
5
+ class Middleware
6
+ # Create Rails::Auth::ACL::Middleware from the args you'd pass to Rails::Auth::ACL's constructor
7
+ def self.from_acl_config(app, **args)
8
+ new(app, acl: Rails::Auth::ACL.new(**args))
9
+ end
10
+
11
+ # Create a new ACL Middleware object
12
+ #
13
+ # @param [Object] app next app in the Rack middleware chain
14
+ # @param [Hash] acl Rails::Auth::ACL object to authorize the request with
15
+ #
16
+ # @return [Rails::Auth::ACL::Middleware] new ACL middleware instance
17
+ def initialize(app, acl: nil)
18
+ fail ArgumentError, "no acl given" unless acl
19
+
20
+ @app = app
21
+ @acl = acl
22
+ end
23
+
24
+ def call(env)
25
+ fail NotAuthorizedError, "unauthorized request" unless @acl.match(env)
26
+ @app.call(env)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Auth
5
+ class ACL
6
+ # Rules for a particular route
7
+ class Resource
8
+ attr_reader :http_methods, :path, :predicates
9
+
10
+ # Valid HTTP methods
11
+ HTTP_METHODS = %w(GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK).freeze
12
+
13
+ # Options allowed for resource matchers
14
+ VALID_OPTIONS = %w(method path).freeze
15
+
16
+ # @option :options [String] :method HTTP method allowed ("ALL" for all methods)
17
+ # @option :options [String] :path path to the resource (regex syntax allowed)
18
+ # @param [Hash] :predicates matchers for this resource
19
+ #
20
+ def initialize(options, predicates)
21
+ fail TypeError, "expected Hash for options" unless options.is_a?(Hash)
22
+ fail TypeError, "expected Hash for predicates" unless predicates.is_a?(Hash)
23
+
24
+ unless (extra_keys = options.keys - VALID_OPTIONS).empty?
25
+ fail ParseError, "unrecognized key in ACL resource: #{extra_keys.first}"
26
+ end
27
+
28
+ @http_methods = extract_methods(options["method"])
29
+ @path = /\A#{options.fetch("path")}\z/
30
+ @predicates = predicates.freeze
31
+ end
32
+
33
+ # Match this resource against the given Rack environment, checking all
34
+ # predicates to ensure at least one of them matches
35
+ #
36
+ # @param [Hash] :env Rack environment
37
+ #
38
+ # @return [Boolean] resource and predicates match the given request
39
+ #
40
+ def match(env)
41
+ return false unless match_method_and_path(env)
42
+ @predicates.any? { |_name, predicate| predicate.match(env) }
43
+ end
44
+
45
+ # Match *only* the request method/path against the given Rack environment.
46
+ # Predicates are NOT checked.
47
+ #
48
+ # @param [Hash] :env Rack environment
49
+ #
50
+ # @return [Boolean] method and path *only* match the given environment
51
+ #
52
+ def match_method_and_path(env)
53
+ return false unless @http_methods.nil? || @http_methods.include?(env["REQUEST_METHOD".freeze])
54
+ return false unless @path =~ env["REQUEST_PATH".freeze]
55
+ true
56
+ end
57
+
58
+ private
59
+
60
+ def extract_methods(methods)
61
+ methods = Array(methods)
62
+
63
+ return nil if methods.include?("ALL")
64
+
65
+ methods.each do |method|
66
+ fail ParseError, "invalid HTTP method: #{method}" unless HTTP_METHODS.include?(method)
67
+ end
68
+
69
+ methods.freeze
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,9 @@
1
+ module Rails
2
+ module Auth
3
+ # Unauthorized!
4
+ NotAuthorizedError = Class.new(StandardError)
5
+
6
+ # Error parsing e.g. an ACL
7
+ ParseError = Class.new(StandardError)
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ # Modular resource-based authentication and authorization for Rails/Rack
5
+ module Auth
6
+ # Rack environment key for all rails-auth principals
7
+ PRINCIPALS_ENV_KEY = "rails-auth.principals".freeze
8
+
9
+ # Functionality for storing principals in the Rack environment
10
+ module Principals
11
+ # Obtain principals from a Rack environment
12
+ #
13
+ # @param [Hash] :env Rack environment
14
+ #
15
+ def principals(env)
16
+ env.fetch(PRINCIPALS_ENV_KEY, {})
17
+ end
18
+
19
+ # Add a principal to the Rack environment
20
+ #
21
+ # @param [Hash] :env Rack environment
22
+ # @param [String] :type principal type to add to the environment
23
+ # @param [Object] :principal principal object to add to the environment
24
+ #
25
+ def add_principal(env, type, principal)
26
+ principals = env[PRINCIPALS_ENV_KEY] ||= {}
27
+
28
+ fail ArgumentError, "principal #{type} already added to request" if principals.key?(type)
29
+ principals[type] = principal
30
+ end
31
+ end
32
+
33
+ # Include these functions in Rails::Auth for convenience
34
+ extend Principals
35
+ end
36
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Core library components that work with any Rack application
4
+ require "rack"
5
+ require "openssl"
6
+
7
+ require "rails/auth/version"
8
+ require "rails/auth/exceptions"
9
+ require "rails/auth/principals"
10
+
11
+ require "rails/auth/acl"
12
+ require "rails/auth/acl/middleware"
13
+ require "rails/auth/acl/resource"
14
+
15
+ require "rails/auth/x509/filter/pem"
16
+ require "rails/auth/x509/filter/java" if defined?(JRUBY_VERSION)
17
+ require "rails/auth/x509/matcher"
18
+ require "rails/auth/x509/middleware"
19
+ require "rails/auth/x509/principal"
@@ -0,0 +1,6 @@
1
+ require "rails/auth/rspec/helper_methods"
2
+ require "rails/auth/rspec/matchers/acl_matchers"
3
+
4
+ RSpec.configure do |config|
5
+ config.include Rails::Auth::RSpec::HelperMethods, acl_spec: true
6
+ end
@@ -0,0 +1,51 @@
1
+ module Rails
2
+ module Auth
3
+ module RSpec
4
+ # RSpec helper methods
5
+ module HelperMethods
6
+ # Creates an Rails::Auth::X509::Principal instance double
7
+ def x509_principal(cn: nil, ou: nil)
8
+ subject = ""
9
+ subject << "CN=#{cn}" if cn
10
+ subject << "OU=#{ou}" if ou
11
+
12
+ instance_double(X509::Principal, subject, cn: cn, ou: ou).tap do |principal|
13
+ allow(principal).to receive(:[]) do |key|
14
+ {
15
+ "CN" => cn,
16
+ "OU" => ou
17
+ }[key.to_s.upcase]
18
+ end
19
+ end
20
+ end
21
+
22
+ # Creates a principals hash containing a single X.509 principal instance double
23
+ def x509_principal_hash(**args)
24
+ { "x509" => x509_principal(**args) }
25
+ end
26
+
27
+ Rails::Auth::ACL::Resource::HTTP_METHODS.each do |method|
28
+ define_method("#{method.downcase}_request") do |principals: {}|
29
+ path = self.class.description
30
+
31
+ # Warn if methods are improperly used
32
+ unless path.chars[0] == "/"
33
+ fail ArgumentError, "expected #{path} to start with '/'"
34
+ end
35
+
36
+ env = {
37
+ "REQUEST_METHOD" => method,
38
+ "REQUEST_PATH" => self.class.description
39
+ }
40
+
41
+ principals.each do |type, value|
42
+ Rails::Auth.add_principal(env, type.to_s, value)
43
+ end
44
+
45
+ env
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ RSpec::Matchers.define(:permit) do |env|
2
+ description do
3
+ method = env["REQUEST_METHOD"]
4
+ principals = Rails::Auth.principals(env)
5
+ message = "allow #{method}s by "
6
+
7
+ return message << "unauthenticated clients" if principals.count.zero?
8
+
9
+ message << principals.values.map(&:inspect).join(", ")
10
+ end
11
+
12
+ match { |acl| acl.match(env) }
13
+ end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rails
4
+ # Pluggable authentication and authorization for Rack/Rails
2
5
  module Auth
3
- VERSION = "0.0.0"
6
+ VERSION = "0.0.1".freeze
4
7
  end
5
8
  end
@@ -0,0 +1,25 @@
1
+ require "java"
2
+ require "stringio"
3
+
4
+ module Rails
5
+ module Auth
6
+ module X509
7
+ module Filter
8
+ # Support for extracting X509::Principals from Java's sun.security.x509.X509CertImpl
9
+ class Java
10
+ def call(cert)
11
+ OpenSSL::X509::Certificate.new(extract_der(cert)).freeze
12
+ end
13
+
14
+ private
15
+
16
+ def extract_der(cert)
17
+ stringio = StringIO.new
18
+ cert.derEncode(stringio.to_outputstream)
19
+ stringio.string
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ module Rails
2
+ module Auth
3
+ module X509
4
+ module Filter
5
+ # Support for extracting X509::Principals from Privacy Enhanced Mail (PEM) certificates
6
+ class Pem
7
+ def call(pem)
8
+ OpenSSL::X509::Certificate.new(pem).freeze
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ module Rails
2
+ module Auth
3
+ module X509
4
+ # Predicate matcher for making assertions about X.509 principals
5
+ class Matcher
6
+ # @option options [String] cn Common Name of the subject
7
+ # @option options [String] ou Organizational Unit of the subject
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ # @param [Hash] env Rack environment
13
+ def match(env)
14
+ principal = Rails::Auth.principals(env)["x509"]
15
+ return false unless principal
16
+
17
+ @options.all? { |name, value| principal[name] == value }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end