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.
- checksums.yaml +4 -4
- data/.rspec +2 -0
- data/.rubocop.yml +18 -0
- data/.travis.yml +9 -1
- data/CONDUCT.md +5 -0
- data/CONTRIBUTING.md +14 -0
- data/Gemfile +11 -2
- data/Guardfile +5 -0
- data/LICENSE +202 -0
- data/README.md +267 -7
- data/Rakefile +3 -1
- data/lib/rails/auth.rb +3 -6
- data/lib/rails/auth/acl.rb +88 -0
- data/lib/rails/auth/acl/matchers/allow_all.rb +23 -0
- data/lib/rails/auth/acl/middleware.rb +31 -0
- data/lib/rails/auth/acl/resource.rb +74 -0
- data/lib/rails/auth/exceptions.rb +9 -0
- data/lib/rails/auth/principals.rb +36 -0
- data/lib/rails/auth/rack.rb +19 -0
- data/lib/rails/auth/rspec.rb +6 -0
- data/lib/rails/auth/rspec/helper_methods.rb +51 -0
- data/lib/rails/auth/rspec/matchers/acl_matchers.rb +13 -0
- data/lib/rails/auth/version.rb +4 -1
- data/lib/rails/auth/x509/filter/java.rb +25 -0
- data/lib/rails/auth/x509/filter/pem.rb +14 -0
- data/lib/rails/auth/x509/matcher.rb +22 -0
- data/lib/rails/auth/x509/middleware.rb +78 -0
- data/lib/rails/auth/x509/principal.rb +41 -0
- data/rails-auth.gemspec +20 -9
- data/spec/fixtures/example_acl.yml +27 -0
- data/spec/rails/auth/acl/matchers/allow_all_spec.rb +32 -0
- data/spec/rails/auth/acl/middleware_spec.rb +24 -0
- data/spec/rails/auth/acl/resource_spec.rb +105 -0
- data/spec/rails/auth/acl_spec.rb +28 -0
- data/spec/rails/auth/principals_spec.rb +36 -0
- data/spec/rails/auth/rspec/helper_methods_spec.rb +42 -0
- data/spec/rails/auth/rspec/matchers/acl_matchers_spec.rb +20 -0
- data/spec/rails/auth/x509/matcher_spec.rb +21 -0
- data/spec/rails/auth/x509/middleware_spec.rb +74 -0
- data/spec/rails/auth/x509/principal_spec.rb +27 -0
- data/spec/rails/auth_spec.rb +5 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/support/claims_predicate.rb +11 -0
- data/spec/support/create_certs.rb +59 -0
- metadata +60 -24
- data/bin/console +0 -14
- data/bin/setup +0 -8
data/Rakefile
CHANGED
data/lib/rails/auth.rb
CHANGED
@@ -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,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,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
|
data/lib/rails/auth/version.rb
CHANGED
@@ -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
|