devise_scim 0.1.11
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 +7 -0
- data/AGENTS.md +124 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +348 -0
- data/Rakefile +21 -0
- data/app/controllers/devise_scim/application_controller.rb +69 -0
- data/app/controllers/devise_scim/groups_controller.rb +67 -0
- data/app/controllers/devise_scim/resource_types_controller.rb +43 -0
- data/app/controllers/devise_scim/schemas_controller.rb +55 -0
- data/app/controllers/devise_scim/service_provider_controller.rb +34 -0
- data/app/controllers/devise_scim/users_controller.rb +281 -0
- data/docs/contributing.md +163 -0
- data/docs/custom_adapter.md +456 -0
- data/docs/idp_setup.md +335 -0
- data/docs/multi_tenant.md +328 -0
- data/docs/testing.md +444 -0
- data/lib/devise_scim/auth/base_strategy.rb +16 -0
- data/lib/devise_scim/auth/oauth_strategy.rb +28 -0
- data/lib/devise_scim/auth/token_strategy.rb +25 -0
- data/lib/devise_scim/concerns/scim_group_identifiable.rb +21 -0
- data/lib/devise_scim/concerns/scim_tenant.rb +41 -0
- data/lib/devise_scim/configuration.rb +92 -0
- data/lib/devise_scim/engine.rb +15 -0
- data/lib/devise_scim/filter/arel_visitor.rb +77 -0
- data/lib/devise_scim/filter/parser.rb +190 -0
- data/lib/devise_scim/middleware/authenticator.rb +51 -0
- data/lib/devise_scim/minitest.rb +57 -0
- data/lib/devise_scim/models/scim_tenant.rb +14 -0
- data/lib/devise_scim/models/scim_tenant_user.rb +15 -0
- data/lib/devise_scim/routing.rb +43 -0
- data/lib/devise_scim/rspec/factories.rb +17 -0
- data/lib/devise_scim/rspec/scim_helpers.rb +43 -0
- data/lib/devise_scim/rspec/shared_examples/discovery_endpoints.rb +94 -0
- data/lib/devise_scim/rspec/shared_examples/groups_endpoint.rb +148 -0
- data/lib/devise_scim/rspec/shared_examples/users_endpoint.rb +301 -0
- data/lib/devise_scim/rspec.rb +7 -0
- data/lib/devise_scim/scim/error.rb +59 -0
- data/lib/devise_scim/scim/group.rb +66 -0
- data/lib/devise_scim/scim/list_response.rb +32 -0
- data/lib/devise_scim/scim/patch_operation.rb +55 -0
- data/lib/devise_scim/scim/user.rb +161 -0
- data/lib/devise_scim/scim_adapter.rb +84 -0
- data/lib/devise_scim/version.rb +5 -0
- data/lib/devise_scim.rb +48 -0
- data/lib/generators/devise_scim/adapter_generator.rb +17 -0
- data/lib/generators/devise_scim/install_generator.rb +117 -0
- data/lib/generators/devise_scim/templates/add_scim_to_tenant.rb.tt +17 -0
- data/lib/generators/devise_scim/templates/add_scim_to_users.rb.tt +15 -0
- data/lib/generators/devise_scim/templates/application_scim_adapter.rb.tt +34 -0
- data/lib/generators/devise_scim/templates/create_scim_tenant_users.rb.tt +22 -0
- data/lib/generators/devise_scim/templates/create_scim_tenants.rb.tt +18 -0
- data/lib/generators/devise_scim/templates/devise_scim.rb.tt +53 -0
- data/sig/devise_scim.rbs +4 -0
- metadata +146 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module Filter
|
|
5
|
+
# Translates a Filter AST into Arel conditions applied to an AR scope.
|
|
6
|
+
class ArelVisitor
|
|
7
|
+
SCIM_TO_AR = {
|
|
8
|
+
"userName" => "email",
|
|
9
|
+
"externalId" => "scim_uid",
|
|
10
|
+
"active" => "scim_active",
|
|
11
|
+
"id" => "id",
|
|
12
|
+
"emails" => "email",
|
|
13
|
+
"emails.value" => "email",
|
|
14
|
+
"name.givenName" => "first_name",
|
|
15
|
+
"name.familyName" => "last_name"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(model)
|
|
19
|
+
@model = model
|
|
20
|
+
@table = model.arel_table
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def apply(ast, scope)
|
|
24
|
+
scope.where(visit(ast))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def visit(node)
|
|
30
|
+
case node
|
|
31
|
+
when Comparison then visit_comparison(node)
|
|
32
|
+
when Conjunction then visit(node.left).and(visit(node.right))
|
|
33
|
+
when Disjunction then visit(node.left).or(visit(node.right))
|
|
34
|
+
when AttrPath then visit_attr_path(node)
|
|
35
|
+
else raise InvalidFilter, "Unknown AST node: #{node.class}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
40
|
+
def visit_comparison(node)
|
|
41
|
+
col_name = SCIM_TO_AR[node.attr_path] || node.attr_path
|
|
42
|
+
col = resolve_column(col_name)
|
|
43
|
+
val = node.value
|
|
44
|
+
|
|
45
|
+
case node.op
|
|
46
|
+
when "eq" then col.eq(val)
|
|
47
|
+
when "ne" then col.not_eq(val)
|
|
48
|
+
when "co" then col.matches("%#{sanitize_like(val)}%")
|
|
49
|
+
when "sw" then col.matches("#{sanitize_like(val)}%")
|
|
50
|
+
when "ew" then col.matches("%#{sanitize_like(val)}")
|
|
51
|
+
when "pr" then col.not_eq(nil)
|
|
52
|
+
when "gt" then col.gt(val)
|
|
53
|
+
when "ge" then col.gteq(val)
|
|
54
|
+
when "lt" then col.lt(val)
|
|
55
|
+
when "le" then col.lteq(val)
|
|
56
|
+
else raise InvalidFilter, "Unknown operator '#{node.op}'"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
60
|
+
|
|
61
|
+
def visit_attr_path(node)
|
|
62
|
+
col_name = SCIM_TO_AR[node.attribute] || node.attribute
|
|
63
|
+
resolve_column(col_name).not_eq(nil)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resolve_column(col_name)
|
|
67
|
+
raise InvalidFilter, "Unknown attribute '#{col_name}'" unless @model.column_names.include?(col_name)
|
|
68
|
+
|
|
69
|
+
@table[col_name]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sanitize_like(str)
|
|
73
|
+
str.gsub(/[%_\\]/) { |c| "\\#{c}" }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module Filter
|
|
5
|
+
Comparison = Struct.new(:attr_path, :op, :value, keyword_init: true)
|
|
6
|
+
Conjunction = Struct.new(:left, :right, keyword_init: true)
|
|
7
|
+
Disjunction = Struct.new(:left, :right, keyword_init: true)
|
|
8
|
+
AttrPath = Struct.new(:attribute, :sub_filter, :sub_attr, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Recursive descent parser for SCIM filter expressions (RFC 7644 §3.4.2.2).
|
|
11
|
+
# Supported: eq/ne/co/sw/ew/pr/gt/ge/lt/le, and/or, attr[sub-filter].sub-attr comparisons.
|
|
12
|
+
class Parser # rubocop:disable Metrics/ClassLength
|
|
13
|
+
class ParseError < ::DeviseScim::InvalidFilter; end
|
|
14
|
+
|
|
15
|
+
Token = Struct.new(:type, :value, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
COMP_OPS = %w[eq ne co sw ew pr gt ge lt le].freeze
|
|
18
|
+
|
|
19
|
+
def self.parse(str)
|
|
20
|
+
new(str).parse
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(str)
|
|
24
|
+
@tokens = tokenize(str)
|
|
25
|
+
@pos = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse
|
|
29
|
+
ast = parse_or
|
|
30
|
+
raise ParseError, "Unexpected token '#{current&.value}'" unless at_end?
|
|
31
|
+
|
|
32
|
+
ast
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
38
|
+
def tokenize(str)
|
|
39
|
+
tokens = []
|
|
40
|
+
s = str.strip
|
|
41
|
+
until s.empty?
|
|
42
|
+
s = s.lstrip
|
|
43
|
+
break if s.empty?
|
|
44
|
+
|
|
45
|
+
# Use explicit match objects so String#gsub doesn't clobber $&.
|
|
46
|
+
if (m = /\A"((?:[^"\\]|\\.)*)"/.match(s))
|
|
47
|
+
tokens << Token.new(type: :string, value: m[1].gsub('\\"', '"'))
|
|
48
|
+
s = s[m[0].length..]
|
|
49
|
+
elsif (m = /\Atrue\b/i.match(s))
|
|
50
|
+
tokens << Token.new(type: :boolean, value: true)
|
|
51
|
+
s = s[m[0].length..]
|
|
52
|
+
elsif (m = /\Afalse\b/i.match(s))
|
|
53
|
+
tokens << Token.new(type: :boolean, value: false)
|
|
54
|
+
s = s[m[0].length..]
|
|
55
|
+
elsif (m = /\Anull\b/i.match(s))
|
|
56
|
+
tokens << Token.new(type: :null, value: nil)
|
|
57
|
+
s = s[m[0].length..]
|
|
58
|
+
elsif (m = /\Anot\b/i.match(s))
|
|
59
|
+
tokens << Token.new(type: :not, value: "not")
|
|
60
|
+
s = s[m[0].length..]
|
|
61
|
+
elsif (m = /\Aand\b/i.match(s))
|
|
62
|
+
tokens << Token.new(type: :and, value: "and")
|
|
63
|
+
s = s[m[0].length..]
|
|
64
|
+
elsif (m = /\Aor\b/i.match(s))
|
|
65
|
+
tokens << Token.new(type: :or, value: "or")
|
|
66
|
+
s = s[m[0].length..]
|
|
67
|
+
elsif (m = /\A(eq|ne|co|sw|ew|pr|gt|ge|lt|le)\b/i.match(s))
|
|
68
|
+
tokens << Token.new(type: :op, value: m[1].downcase)
|
|
69
|
+
s = s[m[0].length..]
|
|
70
|
+
elsif s.start_with?("(")
|
|
71
|
+
tokens << Token.new(type: :lparen, value: "(")
|
|
72
|
+
s = s[1..]
|
|
73
|
+
elsif s.start_with?(")")
|
|
74
|
+
tokens << Token.new(type: :rparen, value: ")")
|
|
75
|
+
s = s[1..]
|
|
76
|
+
elsif s.start_with?("[")
|
|
77
|
+
tokens << Token.new(type: :lbracket, value: "[")
|
|
78
|
+
s = s[1..]
|
|
79
|
+
elsif s.start_with?("]")
|
|
80
|
+
tokens << Token.new(type: :rbracket, value: "]")
|
|
81
|
+
s = s[1..]
|
|
82
|
+
elsif s.start_with?(".")
|
|
83
|
+
tokens << Token.new(type: :dot, value: ".")
|
|
84
|
+
s = s[1..]
|
|
85
|
+
elsif (m = /\A[\w:-]+/.match(s))
|
|
86
|
+
tokens << Token.new(type: :identifier, value: m[0])
|
|
87
|
+
s = s[m[0].length..]
|
|
88
|
+
elsif (m = /\A-?\d+(\.\d+)?/.match(s))
|
|
89
|
+
tokens << Token.new(type: :number, value: m[0].to_f)
|
|
90
|
+
s = s[m[0].length..]
|
|
91
|
+
else
|
|
92
|
+
raise ParseError, "Unexpected character '#{s[0]}'"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
tokens
|
|
96
|
+
end
|
|
97
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
98
|
+
|
|
99
|
+
def parse_or
|
|
100
|
+
left = parse_and
|
|
101
|
+
while current&.type == :or
|
|
102
|
+
advance
|
|
103
|
+
left = Disjunction.new(left: left, right: parse_and)
|
|
104
|
+
end
|
|
105
|
+
left
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_and
|
|
109
|
+
left = parse_primary
|
|
110
|
+
while current&.type == :and
|
|
111
|
+
advance
|
|
112
|
+
left = Conjunction.new(left: left, right: parse_primary)
|
|
113
|
+
end
|
|
114
|
+
left
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
118
|
+
def parse_primary
|
|
119
|
+
if current&.type == :lparen
|
|
120
|
+
advance
|
|
121
|
+
node = parse_or
|
|
122
|
+
expect!(:rparen)
|
|
123
|
+
return node
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
attr = expect!(:identifier).value
|
|
127
|
+
|
|
128
|
+
if current&.type == :lbracket
|
|
129
|
+
advance
|
|
130
|
+
sub_filter = parse_or
|
|
131
|
+
expect!(:rbracket)
|
|
132
|
+
sub_attr = extract_sub_attr
|
|
133
|
+
if current&.type == :op
|
|
134
|
+
op = advance.value
|
|
135
|
+
val = parse_value
|
|
136
|
+
Comparison.new(attr_path: sub_attr ? "#{attr}.#{sub_attr}" : attr, op: op, value: val)
|
|
137
|
+
else
|
|
138
|
+
AttrPath.new(attribute: attr, sub_filter: sub_filter, sub_attr: sub_attr)
|
|
139
|
+
end
|
|
140
|
+
elsif current&.type == :op
|
|
141
|
+
op = advance.value
|
|
142
|
+
val = op == "pr" ? nil : parse_value
|
|
143
|
+
Comparison.new(attr_path: attr, op: op, value: val)
|
|
144
|
+
else
|
|
145
|
+
raise ParseError, "Expected comparator after '#{attr}', got #{current&.value.inspect}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
149
|
+
|
|
150
|
+
def extract_sub_attr
|
|
151
|
+
return nil unless current&.type == :dot
|
|
152
|
+
|
|
153
|
+
advance
|
|
154
|
+
expect!(:identifier).value
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parse_value
|
|
158
|
+
tok = current
|
|
159
|
+
case tok&.type
|
|
160
|
+
when :string, :boolean, :null, :number
|
|
161
|
+
advance
|
|
162
|
+
tok.value
|
|
163
|
+
else
|
|
164
|
+
raise ParseError, "Expected value, got #{tok&.value.inspect}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def current
|
|
169
|
+
@tokens[@pos]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def advance
|
|
173
|
+
tok = @tokens[@pos]
|
|
174
|
+
@pos += 1
|
|
175
|
+
tok
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def at_end?
|
|
179
|
+
@pos >= @tokens.length
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def expect!(type)
|
|
183
|
+
tok = advance
|
|
184
|
+
raise ParseError, "Expected #{type}, got #{tok&.type} (#{tok&.value.inspect})" unless tok&.type == type
|
|
185
|
+
|
|
186
|
+
tok
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module Middleware
|
|
5
|
+
class Authenticator
|
|
6
|
+
SCIM_CONTENT_TYPE = "application/scim+json"
|
|
7
|
+
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
return @app.call(env) unless scim_path?(env["PATH_INFO"])
|
|
14
|
+
|
|
15
|
+
result = build_strategy.authenticate(env)
|
|
16
|
+
|
|
17
|
+
if result.nil?
|
|
18
|
+
unauthorized_response(env)
|
|
19
|
+
else
|
|
20
|
+
env["devise_scim.tenant"] = result unless result == :ok
|
|
21
|
+
@app.call(env)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def scim_path?(path)
|
|
28
|
+
path.start_with?(DeviseScim.configuration.route_prefix)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_strategy
|
|
32
|
+
if DeviseScim.configuration.auth_method == :oauth
|
|
33
|
+
Auth::OauthStrategy.new
|
|
34
|
+
else
|
|
35
|
+
Auth::TokenStrategy.new
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def unauthorized_response(env)
|
|
40
|
+
# Prevent Warden from intercepting the 401 and attempting its failure app.
|
|
41
|
+
env["warden"].custom_failure! if env["warden"].respond_to?(:custom_failure!)
|
|
42
|
+
body = {
|
|
43
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
44
|
+
status: "401",
|
|
45
|
+
detail: "Unauthorized"
|
|
46
|
+
}.to_json
|
|
47
|
+
[401, { "Content-Type" => SCIM_CONTENT_TYPE }, [body]]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module Minitest
|
|
5
|
+
module ScimAssertions
|
|
6
|
+
def assert_scim_status(response, expected_status)
|
|
7
|
+
assert_equal expected_status.to_s, response.status.to_s,
|
|
8
|
+
"Expected SCIM status #{expected_status}, got #{response.status}\n#{response.body}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def assert_scim_content_type(response)
|
|
12
|
+
assert_includes response.content_type, "application/scim+json",
|
|
13
|
+
"Expected Content-Type application/scim+json"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def assert_scim_schema(response, expected_schema)
|
|
17
|
+
body = JSON.parse(response.body)
|
|
18
|
+
assert_includes body["schemas"], expected_schema,
|
|
19
|
+
"Expected schema #{expected_schema.inspect} in #{body["schemas"].inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def assert_scim_list_response(response)
|
|
23
|
+
body = JSON.parse(response.body)
|
|
24
|
+
assert_scim_schema(response, DeviseScim::Scim::LIST_RESPONSE_SCHEMA)
|
|
25
|
+
assert body.key?("totalResults"), "Expected totalResults key in ListResponse"
|
|
26
|
+
assert body.key?("Resources"), "Expected Resources key in ListResponse"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def assert_scim_error(response, expected_status: nil)
|
|
30
|
+
body = JSON.parse(response.body)
|
|
31
|
+
assert_includes body["schemas"], DeviseScim::Scim::ERROR_SCHEMA,
|
|
32
|
+
"Expected SCIM error schema in response"
|
|
33
|
+
assert_equal expected_status.to_s, body["status"] if expected_status
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scim_json(response)
|
|
37
|
+
JSON.parse(response.body)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scim_auth_headers(token)
|
|
41
|
+
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scim_user_payload(user_name:, **attrs)
|
|
45
|
+
{ "schemas" => [DeviseScim::Scim::USER_SCHEMA], "userName" => user_name }
|
|
46
|
+
.merge(attrs.transform_keys(&:to_s))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def scim_patch_payload(*operations)
|
|
50
|
+
{
|
|
51
|
+
"schemas" => ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
52
|
+
"Operations" => operations
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class ScimTenant < ActiveRecord::Base
|
|
5
|
+
self.table_name = "scim_tenants"
|
|
6
|
+
|
|
7
|
+
include DeviseScim::Concerns::ScimTenant
|
|
8
|
+
|
|
9
|
+
has_many :scim_tenant_users, class_name: "DeviseScim::ScimTenantUser",
|
|
10
|
+
foreign_key: :scim_tenant_id, dependent: :destroy
|
|
11
|
+
belongs_to :doorkeeper_application,
|
|
12
|
+
class_name: "Doorkeeper::Application", optional: true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class ScimTenantUser < ActiveRecord::Base
|
|
5
|
+
self.table_name = "scim_tenant_users"
|
|
6
|
+
|
|
7
|
+
# Default associations use built-in DeviseScim::ScimTenant and the host app's
|
|
8
|
+
# User model. When config.tenant_model is customized, host apps override these
|
|
9
|
+
# associations (and the FK) in their own initializer.
|
|
10
|
+
belongs_to :scim_tenant,
|
|
11
|
+
class_name: "DeviseScim::ScimTenant",
|
|
12
|
+
foreign_key: :scim_tenant_id
|
|
13
|
+
belongs_to :user, foreign_key: :user_id
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module Routing
|
|
5
|
+
def scim_for(_resource, at: DeviseScim.configuration.route_prefix)
|
|
6
|
+
config = DeviseScim.configuration
|
|
7
|
+
scope at, format: false do
|
|
8
|
+
draw_user_routes
|
|
9
|
+
draw_group_routes if config.enable_groups
|
|
10
|
+
post "oauth/token", to: "doorkeeper/tokens#create" if config.auth_method == :oauth
|
|
11
|
+
draw_discovery_routes
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def draw_user_routes
|
|
18
|
+
get "Users", to: "devise_scim/users#index"
|
|
19
|
+
post "Users", to: "devise_scim/users#create"
|
|
20
|
+
get "Users/:id", to: "devise_scim/users#show"
|
|
21
|
+
put "Users/:id", to: "devise_scim/users#replace"
|
|
22
|
+
patch "Users/:id", to: "devise_scim/users#update"
|
|
23
|
+
delete "Users/:id", to: "devise_scim/users#destroy"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def draw_group_routes
|
|
27
|
+
get "Groups", to: "devise_scim/groups#index"
|
|
28
|
+
post "Groups", to: "devise_scim/groups#create"
|
|
29
|
+
get "Groups/:id", to: "devise_scim/groups#show"
|
|
30
|
+
put "Groups/:id", to: "devise_scim/groups#replace"
|
|
31
|
+
patch "Groups/:id", to: "devise_scim/groups#update"
|
|
32
|
+
delete "Groups/:id", to: "devise_scim/groups#destroy"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def draw_discovery_routes
|
|
36
|
+
get "ServiceProviderConfig", to: "devise_scim/service_provider#show"
|
|
37
|
+
get "Schemas", to: "devise_scim/schemas#index"
|
|
38
|
+
get "ResourceTypes", to: "devise_scim/resource_types#index"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
ActionDispatch::Routing::Mapper.include DeviseScim::Routing
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(FactoryBot)
|
|
4
|
+
|
|
5
|
+
FactoryBot.define do
|
|
6
|
+
factory :scim_tenant, class: "DeviseScim::ScimTenant" do
|
|
7
|
+
sequence(:name) { |n| "Test Tenant #{n}" }
|
|
8
|
+
auth_method { "token" }
|
|
9
|
+
active { true }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
factory :scim_tenant_user, class: "DeviseScim::ScimTenantUser" do
|
|
13
|
+
association :scim_tenant
|
|
14
|
+
active { true }
|
|
15
|
+
provisioned_at { Time.current }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
module RSpec
|
|
5
|
+
module ScimHelpers
|
|
6
|
+
def scim_json(body)
|
|
7
|
+
JSON.parse(body)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def scim_prefix
|
|
11
|
+
DeviseScim.configuration.route_prefix
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def scim_auth_headers(token)
|
|
15
|
+
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def scim_user_payload(user_name:, **attrs)
|
|
19
|
+
base = { "schemas" => [DeviseScim::Scim::USER_SCHEMA], "userName" => user_name }
|
|
20
|
+
base.merge(attrs.transform_keys(&:to_s))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def scim_patch_payload(*operations)
|
|
24
|
+
{
|
|
25
|
+
"schemas" => ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
26
|
+
"Operations" => operations
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def scim_replace_op(path, value)
|
|
31
|
+
{ "op" => "replace", "path" => path, "value" => value }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def scim_add_op(path, value)
|
|
35
|
+
{ "op" => "add", "path" => path, "value" => value }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def scim_remove_op(path)
|
|
39
|
+
{ "op" => "remove", "path" => path }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
RSpec.shared_examples "SCIM discovery endpoints" do |_options = {}|
|
|
5
|
+
include DeviseScim::RSpec::ScimHelpers
|
|
6
|
+
|
|
7
|
+
let(:_scim_test_token) { "scim-test-token-#{SecureRandom.hex(8)}" }
|
|
8
|
+
let(:_scim_headers) { scim_auth_headers(_scim_test_token) }
|
|
9
|
+
|
|
10
|
+
before do
|
|
11
|
+
DeviseScim.configure do |c|
|
|
12
|
+
c.tenancy = :single
|
|
13
|
+
c.auth_method = :token
|
|
14
|
+
c.token = _scim_test_token
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
after { DeviseScim.reset_configuration! }
|
|
19
|
+
|
|
20
|
+
# ── ServiceProviderConfig ────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
describe "GET /ServiceProviderConfig" do
|
|
23
|
+
it "returns 200 with the correct schema" do
|
|
24
|
+
get "#{scim_prefix}/ServiceProviderConfig", headers: _scim_headers
|
|
25
|
+
expect(response).to have_http_status(:ok)
|
|
26
|
+
body = scim_json(response.body)
|
|
27
|
+
expect(body["schemas"]).to include("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "advertises patch support" do
|
|
31
|
+
get "#{scim_prefix}/ServiceProviderConfig", headers: _scim_headers
|
|
32
|
+
body = scim_json(response.body)
|
|
33
|
+
expect(body.dig("patch", "supported")).to be(true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "advertises filter support" do
|
|
37
|
+
get "#{scim_prefix}/ServiceProviderConfig", headers: _scim_headers
|
|
38
|
+
body = scim_json(response.body)
|
|
39
|
+
expect(body.dig("filter", "supported")).to be(true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "sets Content-Type to application/scim+json" do
|
|
43
|
+
get "#{scim_prefix}/ServiceProviderConfig", headers: _scim_headers
|
|
44
|
+
expect(response.content_type).to include("application/scim+json")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ── Schemas ──────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe "GET /Schemas" do
|
|
51
|
+
it "returns 200 with the User schema" do
|
|
52
|
+
get "#{scim_prefix}/Schemas", headers: _scim_headers
|
|
53
|
+
expect(response).to have_http_status(:ok)
|
|
54
|
+
body = scim_json(response.body)
|
|
55
|
+
ids = body["Resources"].map { |r| r["id"] }
|
|
56
|
+
expect(ids).to include(DeviseScim::Scim::USER_SCHEMA)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context "with groups enabled" do
|
|
60
|
+
before { DeviseScim.configure { |c| c.enable_groups = true } }
|
|
61
|
+
|
|
62
|
+
it "includes the Group schema" do
|
|
63
|
+
get "#{scim_prefix}/Schemas", headers: _scim_headers
|
|
64
|
+
body = scim_json(response.body)
|
|
65
|
+
ids = body["Resources"].map { |r| r["id"] }
|
|
66
|
+
expect(ids).to include(DeviseScim::Scim::GROUP_SCHEMA)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ── ResourceTypes ────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe "GET /ResourceTypes" do
|
|
74
|
+
it "returns 200 with the User resource type" do
|
|
75
|
+
get "#{scim_prefix}/ResourceTypes", headers: _scim_headers
|
|
76
|
+
expect(response).to have_http_status(:ok)
|
|
77
|
+
body = scim_json(response.body)
|
|
78
|
+
names = body["Resources"].map { |r| r["name"] }
|
|
79
|
+
expect(names).to include("User")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
context "with groups enabled" do
|
|
83
|
+
before { DeviseScim.configure { |c| c.enable_groups = true } }
|
|
84
|
+
|
|
85
|
+
it "includes the Group resource type" do
|
|
86
|
+
get "#{scim_prefix}/ResourceTypes", headers: _scim_headers
|
|
87
|
+
body = scim_json(response.body)
|
|
88
|
+
names = body["Resources"].map { |r| r["name"] }
|
|
89
|
+
expect(names).to include("Group")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
# rubocop:enable Metrics/BlockLength
|