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,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
# Groups controller delegates all model interaction to the host app's ScimAdapter subclass.
|
|
5
|
+
# The gem handles the SCIM protocol layer; the adapter handles group lifecycle.
|
|
6
|
+
class GroupsController < ApplicationController
|
|
7
|
+
def index
|
|
8
|
+
render_scim(Scim::ListResponse.new(resources: []))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
scim_g = Scim::Group.from_h(parsed_body)
|
|
13
|
+
adapter = scim_adapter_for(nil, scim_g)
|
|
14
|
+
adapter.handle_group_create
|
|
15
|
+
render_scim(adapter.group_to_scim, status: :created)
|
|
16
|
+
rescue NotImplementedError => e
|
|
17
|
+
render_scim(Scim::Error.server_error(e.message), status: :internal_server_error)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show
|
|
21
|
+
scim_g = build_scim_group_for_id(params[:id])
|
|
22
|
+
adapter = scim_adapter_for(nil, scim_g)
|
|
23
|
+
render_scim(adapter.group_to_scim)
|
|
24
|
+
rescue NotImplementedError => e
|
|
25
|
+
render_scim(Scim::Error.server_error(e.message), status: :internal_server_error)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def replace
|
|
29
|
+
scim_g = Scim::Group.from_h(parsed_body)
|
|
30
|
+
adapter = scim_adapter_for(nil, scim_g)
|
|
31
|
+
adapter.handle_group_update
|
|
32
|
+
render_scim(adapter.group_to_scim)
|
|
33
|
+
rescue NotImplementedError => e
|
|
34
|
+
render_scim(Scim::Error.server_error(e.message), status: :internal_server_error)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update
|
|
38
|
+
scim_g = Scim::Group.from_h(parsed_body)
|
|
39
|
+
adapter = scim_adapter_for(nil, scim_g)
|
|
40
|
+
adapter.handle_group_update
|
|
41
|
+
render_scim(adapter.group_to_scim)
|
|
42
|
+
rescue NotImplementedError => e
|
|
43
|
+
render_scim(Scim::Error.server_error(e.message), status: :internal_server_error)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def destroy
|
|
47
|
+
scim_g = build_scim_group_for_id(params[:id])
|
|
48
|
+
adapter = scim_adapter_for(nil, scim_g)
|
|
49
|
+
adapter.handle_group_destroy
|
|
50
|
+
head :no_content
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def parsed_body
|
|
56
|
+
@parsed_body ||= JSON.parse(request.body.read)
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
{}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_scim_group_for_id(id)
|
|
62
|
+
g = Scim::Group.new
|
|
63
|
+
g.id = id
|
|
64
|
+
g
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class ResourceTypesController < ApplicationController
|
|
5
|
+
LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
|
6
|
+
RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
types = [user_resource_type]
|
|
10
|
+
types << group_resource_type if DeviseScim.configuration.enable_groups
|
|
11
|
+
payload = {
|
|
12
|
+
"schemas" => [LIST_SCHEMA],
|
|
13
|
+
"totalResults" => types.size,
|
|
14
|
+
"Resources" => types
|
|
15
|
+
}
|
|
16
|
+
render_scim(payload)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def user_resource_type
|
|
22
|
+
prefix = DeviseScim.configuration.route_prefix
|
|
23
|
+
{
|
|
24
|
+
"schemas" => [RESOURCE_TYPE_SCHEMA],
|
|
25
|
+
"id" => "User",
|
|
26
|
+
"name" => "User",
|
|
27
|
+
"endpoint" => "#{prefix}/Users",
|
|
28
|
+
"schema" => Scim::USER_SCHEMA
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def group_resource_type
|
|
33
|
+
prefix = DeviseScim.configuration.route_prefix
|
|
34
|
+
{
|
|
35
|
+
"schemas" => [RESOURCE_TYPE_SCHEMA],
|
|
36
|
+
"id" => "Group",
|
|
37
|
+
"name" => "Group",
|
|
38
|
+
"endpoint" => "#{prefix}/Groups",
|
|
39
|
+
"schema" => Scim::GROUP_SCHEMA
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class SchemasController < ApplicationController
|
|
5
|
+
LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
|
6
|
+
SCHEMA_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema"
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
schemas = [user_schema]
|
|
10
|
+
schemas << group_schema if DeviseScim.configuration.enable_groups
|
|
11
|
+
payload = {
|
|
12
|
+
"schemas" => [LIST_SCHEMA],
|
|
13
|
+
"totalResults" => schemas.size,
|
|
14
|
+
"Resources" => schemas
|
|
15
|
+
}
|
|
16
|
+
render_scim(payload)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def user_schema
|
|
22
|
+
{
|
|
23
|
+
"schemas" => [SCHEMA_SCHEMA],
|
|
24
|
+
"id" => Scim::USER_SCHEMA,
|
|
25
|
+
"name" => "User",
|
|
26
|
+
"description" => "User Account",
|
|
27
|
+
"attributes" => user_attributes
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def group_schema
|
|
32
|
+
{
|
|
33
|
+
"schemas" => [SCHEMA_SCHEMA],
|
|
34
|
+
"id" => Scim::GROUP_SCHEMA,
|
|
35
|
+
"name" => "Group",
|
|
36
|
+
"description" => "Group",
|
|
37
|
+
"attributes" => [
|
|
38
|
+
{ "name" => "displayName", "type" => "string", "multiValued" => false, "required" => true },
|
|
39
|
+
{ "name" => "members", "type" => "complex", "multiValued" => true, "required" => false }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def user_attributes
|
|
45
|
+
[
|
|
46
|
+
{ "name" => "userName", "type" => "string", "multiValued" => false, "required" => true },
|
|
47
|
+
{ "name" => "name", "type" => "complex", "multiValued" => false, "required" => false },
|
|
48
|
+
{ "name" => "emails", "type" => "complex", "multiValued" => true, "required" => false },
|
|
49
|
+
{ "name" => "phoneNumbers", "type" => "complex", "multiValued" => true, "required" => false },
|
|
50
|
+
{ "name" => "active", "type" => "boolean", "multiValued" => false, "required" => false },
|
|
51
|
+
{ "name" => "externalId", "type" => "string", "multiValued" => false, "required" => false }
|
|
52
|
+
]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class ServiceProviderController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
render_scim(service_provider_config)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def service_provider_config
|
|
12
|
+
{
|
|
13
|
+
"schemas" => ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
14
|
+
"patch" => { "supported" => true },
|
|
15
|
+
"bulk" => { "supported" => false, "maxOperations" => 0, "maxPayloadSize" => 0 },
|
|
16
|
+
"filter" => { "supported" => true, "maxResults" => 200 },
|
|
17
|
+
"changePassword" => { "supported" => false },
|
|
18
|
+
"sort" => { "supported" => false },
|
|
19
|
+
"etag" => { "supported" => false },
|
|
20
|
+
"authenticationSchemes" => auth_schemes
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def auth_schemes
|
|
25
|
+
if DeviseScim.configuration.auth_method == :oauth
|
|
26
|
+
[{ "type" => "oauthbearertoken", "name" => "OAuth Bearer Token",
|
|
27
|
+
"description" => "OAuth2 client_credentials grant" }]
|
|
28
|
+
else
|
|
29
|
+
[{ "type" => "oauthbearertoken", "name" => "Bearer Token",
|
|
30
|
+
"description" => "Static bearer token authentication" }]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeviseScim
|
|
4
|
+
class UsersController < ApplicationController # rubocop:disable Metrics/ClassLength
|
|
5
|
+
def index
|
|
6
|
+
scope = apply_filter(tenant_scope)
|
|
7
|
+
scim_users = scope.map { |u| scim_adapter_for(u, Scim::User.new).to_scim }
|
|
8
|
+
render_scim(Scim::ListResponse.new(resources: scim_users))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
scim_u = Scim::User.from_h(parsed_body)
|
|
13
|
+
user = multi_tenant? ? multi_tenant_create(scim_u) : single_tenant_create(scim_u)
|
|
14
|
+
render_scim(scim_adapter_for(user, Scim::User.new).to_scim, status: :created)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def show
|
|
18
|
+
render_scim(scim_adapter_for(find_user!(params[:id]), Scim::User.new).to_scim)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def replace
|
|
22
|
+
user = find_user!(params[:id])
|
|
23
|
+
scim_u = Scim::User.from_h(parsed_body)
|
|
24
|
+
adapter = scim_adapter_for(user, scim_u)
|
|
25
|
+
user.assign_attributes(adapter.attributes_for_update)
|
|
26
|
+
user.save!
|
|
27
|
+
render_scim(scim_adapter_for(user, Scim::User.new).to_scim)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update
|
|
31
|
+
user = find_user!(params[:id])
|
|
32
|
+
ops = Scim::PatchOperation.parse_request(parsed_body)
|
|
33
|
+
apply_patch(user, ops)
|
|
34
|
+
user.save!
|
|
35
|
+
render_scim(scim_adapter_for(user, Scim::User.new).to_scim)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def destroy
|
|
39
|
+
handle_deprovision(find_user!(params[:id]))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parsed_body
|
|
45
|
+
@parsed_body ||= JSON.parse(request.body.read)
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
{}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def apply_filter(scope)
|
|
51
|
+
return scope unless params[:filter].present?
|
|
52
|
+
|
|
53
|
+
ast = Filter::Parser.parse(params[:filter])
|
|
54
|
+
Filter::ArelVisitor.new(devise_model).apply(ast, scope)
|
|
55
|
+
rescue Filter::Parser::ParseError => e
|
|
56
|
+
raise InvalidFilter, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find_user!(id)
|
|
60
|
+
user = tenant_scope.find_by("#{devise_model.table_name}.id" => id)
|
|
61
|
+
raise NotFound, "User #{id} not found" unless user
|
|
62
|
+
|
|
63
|
+
user
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# ── single-tenant ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def single_tenant_create(scim_u)
|
|
69
|
+
existing = find_by_uid_or_email(scim_u)
|
|
70
|
+
|
|
71
|
+
if existing.nil?
|
|
72
|
+
build_and_save_user(scim_u)
|
|
73
|
+
elsif deprovisioned?(existing)
|
|
74
|
+
reprovision_user(existing, scim_u)
|
|
75
|
+
else
|
|
76
|
+
raise Conflict, "User already exists"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_and_save_user(scim_u)
|
|
81
|
+
user = devise_model.new
|
|
82
|
+
adapter = scim_adapter_for(user, scim_u)
|
|
83
|
+
user.assign_attributes(adapter.attributes_for_create)
|
|
84
|
+
set_scim_meta(user, scim_u.external_id)
|
|
85
|
+
user.save!
|
|
86
|
+
adapter.after_provision
|
|
87
|
+
user
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def reprovision_user(user, scim_u)
|
|
91
|
+
user.scim_active = true if col?(:scim_active)
|
|
92
|
+
user.scim_deprovisioned_at = nil if col?(:scim_deprovisioned_at)
|
|
93
|
+
user.scim_source = "scim" if col?(:scim_source)
|
|
94
|
+
adapter = scim_adapter_for(user, scim_u)
|
|
95
|
+
user.assign_attributes(adapter.attributes_for_update)
|
|
96
|
+
user.save!
|
|
97
|
+
adapter.after_provision
|
|
98
|
+
user
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def find_by_uid_or_email(scim_u)
|
|
102
|
+
email = scim_u.user_name || scim_u.primary_email
|
|
103
|
+
uid = scim_u.external_id
|
|
104
|
+
|
|
105
|
+
if uid && col?(:scim_uid)
|
|
106
|
+
devise_model.find_by(scim_uid: uid) || devise_model.find_by(email: email)
|
|
107
|
+
else
|
|
108
|
+
devise_model.find_by(email: email)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ── multi-tenant ───────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def multi_tenant_create(scim_u)
|
|
115
|
+
email = scim_u.user_name || scim_u.primary_email
|
|
116
|
+
existing = devise_model.find_by(email: email)
|
|
117
|
+
|
|
118
|
+
if existing.nil?
|
|
119
|
+
create_and_assign_user(scim_u)
|
|
120
|
+
else
|
|
121
|
+
active_join = tenant_join_for(existing)
|
|
122
|
+
raise Conflict, "User already belongs to this tenant" if active_join
|
|
123
|
+
|
|
124
|
+
other_join = other_tenant_join_for(existing)
|
|
125
|
+
if other_join
|
|
126
|
+
handle_exclusivity_conflict(existing, scim_u, other_join)
|
|
127
|
+
else
|
|
128
|
+
claim_user(existing, scim_u)
|
|
129
|
+
end
|
|
130
|
+
existing
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def create_and_assign_user(scim_u)
|
|
135
|
+
user = devise_model.new
|
|
136
|
+
adapter = scim_adapter_for(user, scim_u)
|
|
137
|
+
user.assign_attributes(adapter.attributes_for_create)
|
|
138
|
+
user.scim_source = "scim" if col?(:scim_source)
|
|
139
|
+
user.save!
|
|
140
|
+
assign_to_tenant(user, scim_u.external_id)
|
|
141
|
+
adapter.after_provision
|
|
142
|
+
user
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def claim_user(user, scim_u)
|
|
146
|
+
user.scim_source = "scim" if col?(:scim_source)
|
|
147
|
+
user.save! if user.changed?
|
|
148
|
+
assign_to_tenant(user, scim_u.external_id, claimed_now: true)
|
|
149
|
+
scim_adapter_for(user, scim_u).after_provision
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def handle_exclusivity_conflict(user, scim_u, other_join)
|
|
153
|
+
config = DeviseScim.configuration
|
|
154
|
+
if config.user_exclusivity == :multiple
|
|
155
|
+
assign_to_tenant(user, scim_u.external_id)
|
|
156
|
+
scim_adapter_for(user, scim_u).after_provision
|
|
157
|
+
elsif config.exclusivity_conflict == :reassign
|
|
158
|
+
other_join.update!(active: false)
|
|
159
|
+
assign_to_tenant(user, scim_u.external_id)
|
|
160
|
+
scim_adapter_for(user, scim_u).after_provision
|
|
161
|
+
else
|
|
162
|
+
raise Conflict, "User already belongs to another tenant"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def tenant_join_for(user)
|
|
167
|
+
ScimTenantUser.find_by("user_id" => user.id,
|
|
168
|
+
tenant_fk_column => current_scim_tenant.id,
|
|
169
|
+
"active" => true)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def other_tenant_join_for(user)
|
|
173
|
+
ScimTenantUser.where("user_id" => user.id, "active" => true)
|
|
174
|
+
.where.not(tenant_fk_column => current_scim_tenant.id)
|
|
175
|
+
.first
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def assign_to_tenant(user, external_id, claimed_now: false)
|
|
179
|
+
attrs = {
|
|
180
|
+
"user_id" => user.id,
|
|
181
|
+
tenant_fk_column => current_scim_tenant.id,
|
|
182
|
+
"scim_uid" => external_id,
|
|
183
|
+
"provisioned_at" => Time.current,
|
|
184
|
+
"active" => true
|
|
185
|
+
}
|
|
186
|
+
attrs["scim_claimed_at"] = Time.current if claimed_now
|
|
187
|
+
ScimTenantUser.create!(attrs)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# ── deprovision ────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def handle_deprovision(user)
|
|
193
|
+
config = DeviseScim.configuration
|
|
194
|
+
source = col?(:scim_source) ? user.scim_source : "scim"
|
|
195
|
+
|
|
196
|
+
if source != "scim"
|
|
197
|
+
case config.deprovision_manual_users
|
|
198
|
+
when false
|
|
199
|
+
Rails.logger.warn "[DeviseScim] Skipping deprovision of manual user #{user.id}"
|
|
200
|
+
return render_scim(scim_adapter_for(user, Scim::User.new).to_scim)
|
|
201
|
+
when :error
|
|
202
|
+
raise Conflict, "Cannot deprovision a manually-created user"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
perform_deprovision(user, config)
|
|
207
|
+
head :no_content
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def perform_deprovision(user, config)
|
|
211
|
+
if multi_tenant?
|
|
212
|
+
ScimTenantUser.find_by("user_id" => user.id,
|
|
213
|
+
tenant_fk_column => current_scim_tenant.id)
|
|
214
|
+
&.update!(active: false)
|
|
215
|
+
end
|
|
216
|
+
if config.soft_delete
|
|
217
|
+
user.scim_deprovisioned_at = Time.current if col?(:scim_deprovisioned_at)
|
|
218
|
+
user.scim_active = false if col?(:scim_active)
|
|
219
|
+
user.save! if user.changed?
|
|
220
|
+
end
|
|
221
|
+
scim_adapter_for(user, Scim::User.new).after_deprovision
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ── patch ──────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
def apply_patch(user, ops)
|
|
227
|
+
ops.each { |op| apply_op(user, op) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
231
|
+
def apply_op(user, patch_op)
|
|
232
|
+
return apply_valuemap(user, patch_op.value) unless patch_op.raw_path
|
|
233
|
+
|
|
234
|
+
case [patch_op.attribute&.downcase, patch_op.sub_attribute&.downcase]
|
|
235
|
+
when ["active", nil]
|
|
236
|
+
user.scim_active = patch_op.value if col?(:scim_active)
|
|
237
|
+
when ["username", nil]
|
|
238
|
+
user.email = patch_op.value
|
|
239
|
+
when ["emails", nil]
|
|
240
|
+
primary = Array(patch_op.value).find { |e| e["primary"] } || Array(patch_op.value).first
|
|
241
|
+
user.email = primary["value"] if primary
|
|
242
|
+
when %w[name givenname]
|
|
243
|
+
user.first_name = patch_op.value if col?(:first_name)
|
|
244
|
+
when %w[name familyname]
|
|
245
|
+
user.last_name = patch_op.value if col?(:last_name)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
249
|
+
|
|
250
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
251
|
+
def apply_valuemap(user, value)
|
|
252
|
+
return unless value.is_a?(Hash)
|
|
253
|
+
|
|
254
|
+
value.each do |key, val|
|
|
255
|
+
case key.downcase
|
|
256
|
+
when "active" then user.scim_active = val if col?(:scim_active)
|
|
257
|
+
when "username" then user.email = val
|
|
258
|
+
when "name"
|
|
259
|
+
user.first_name = val["givenName"] if val["givenName"] && col?(:first_name)
|
|
260
|
+
user.last_name = val["familyName"] if val["familyName"] && col?(:last_name)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
265
|
+
|
|
266
|
+
# ── helpers ────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def set_scim_meta(user, external_id)
|
|
269
|
+
user.scim_uid = external_id if col?(:scim_uid)
|
|
270
|
+
user.scim_source = "scim" if col?(:scim_source)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def col?(name)
|
|
274
|
+
devise_model.column_names.include?(name.to_s)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def deprovisioned?(user)
|
|
278
|
+
col?(:scim_active) && user.scim_active == false
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Contributing Guide
|
|
2
|
+
|
|
3
|
+
## Dev Setup
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
git clone https://github.com/vertigo-prime/devise_scim.git
|
|
7
|
+
cd devise_scim
|
|
8
|
+
bundle install
|
|
9
|
+
bundle exec rspec # should report ~285 examples, 0 failures
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The gem uses [Combustion](https://github.com/pat/combustion) — there is no separate test Rails app to create or maintain. `Combustion.initialize! :all` in `spec/rails_helper.rb` boots a full Rails environment from `spec/internal/` in-process. SQLite is used for the test database; no setup is needed beyond `bundle install`.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Test Configurations
|
|
17
|
+
|
|
18
|
+
The gem must work correctly under three distinct configurations. The spec suite exercises all three via `before`/`after` blocks that temporarily reconfigure the gem and reset after each example or context.
|
|
19
|
+
|
|
20
|
+
**Single-tenant token auth:**
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
DeviseScim.configure do |c|
|
|
24
|
+
c.tenancy = :single
|
|
25
|
+
c.auth_method = :token
|
|
26
|
+
c.token = "test-token"
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Single-tenant OAuth:**
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
DeviseScim.configure do |c|
|
|
34
|
+
c.tenancy = :single
|
|
35
|
+
c.auth_method = :oauth
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Doorkeeper in the Gemfile. OAuth specs stub or use Doorkeeper's test helpers to issue access tokens.
|
|
40
|
+
|
|
41
|
+
**Multi-tenant:**
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
DeviseScim.configure do |c|
|
|
45
|
+
c.tenancy = :multi
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Multi-tenant specs create `DeviseScim::ScimTenant` records and pass their tokens via the `Authorization` header.
|
|
50
|
+
|
|
51
|
+
Always call `DeviseScim.reset_configuration!` in an `after` block (or use the provided RSpec helpers) to avoid leaking configuration between examples.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Running Checks
|
|
56
|
+
|
|
57
|
+
All three checks must pass before submitting a PR:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Run the full test suite
|
|
61
|
+
bundle exec rspec
|
|
62
|
+
|
|
63
|
+
# Lint — fix safe autocorrections first, then review the rest
|
|
64
|
+
bundle exec rubocop --autocorrect
|
|
65
|
+
bundle exec rubocop
|
|
66
|
+
|
|
67
|
+
# Static security analysis
|
|
68
|
+
bundle exec brakeman --force --no-pager
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The CI workflow (`.github/workflows/main.yml`) runs all three. A PR with failing Brakeman output will not be merged regardless of test results.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Test App Structure
|
|
76
|
+
|
|
77
|
+
`spec/internal/` is the Combustion test application. It is a minimal Rails app that exists solely to host the engine during tests.
|
|
78
|
+
|
|
79
|
+
**Key files:**
|
|
80
|
+
|
|
81
|
+
- `spec/internal/db/schema.rb` — defines all tables: `users`, `scim_tenants`, `scim_tenant_users`, `scim_groups`. This schema is loaded via `config.before(:suite)` in `spec/rails_helper.rb`, ensuring AR specs see the correct tables even when the generator spec has overwritten the in-memory connection.
|
|
82
|
+
- `spec/internal/config/routes.rb` — mounts three `scim_for` variants: the default at `/scim/v2`, a groups-enabled variant at `/scim_groups`, and an OAuth-enabled variant at `/scim_oauth`. The routing spec verifies all three.
|
|
83
|
+
- `spec/internal/app/models/user.rb` — the Devise user model used throughout request specs.
|
|
84
|
+
- `spec/internal/app/models/scim_group.rb` — the group model used by groups endpoint specs.
|
|
85
|
+
- `spec/internal/config/initializers/warden.rb` — patches Devise's `configure_warden!` to be a no-op (Warden middleware is not fully instantiated in the test environment).
|
|
86
|
+
|
|
87
|
+
> [!WARNING]
|
|
88
|
+
> Do not modify `spec/internal/db/schema.rb` without updating all request specs that depend on the table structure. Adding a column that does not exist in production schemas will cause false-positive test passes. Removing a column used by a spec will cause failures that are hard to attribute.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## PR Conventions
|
|
93
|
+
|
|
94
|
+
**Commit messages:**
|
|
95
|
+
|
|
96
|
+
- Imperative mood, present tense: "Add OAuth token rotation" not "Added OAuth token rotation".
|
|
97
|
+
- First line under 72 characters.
|
|
98
|
+
- Body explains **why**, not what — the diff shows what changed.
|
|
99
|
+
- One logical change per commit; one logical change per PR.
|
|
100
|
+
|
|
101
|
+
**For SCIM protocol changes:** link to the relevant RFC section in the PR description. RFC 7643 covers the data model; RFC 7644 covers the protocol (endpoints, filter syntax, PATCH operations). Example: "Per RFC 7644 §3.4.2.2, the `pr` operator should match non-null values."
|
|
102
|
+
|
|
103
|
+
**Test requirements:**
|
|
104
|
+
|
|
105
|
+
- New features must include request specs in `spec/requests/`.
|
|
106
|
+
- Bug fixes must include a regression test that fails before the fix and passes after.
|
|
107
|
+
- Filter system changes (new operators, new attribute mappings) need both a `spec/filter/parser_spec.rb` example and a `spec/filter/arel_visitor_spec.rb` example.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Extending the Filter System
|
|
112
|
+
|
|
113
|
+
The filter pipeline is two files:
|
|
114
|
+
|
|
115
|
+
**`lib/devise_scim/filter/parser.rb`** — tokenizer + recursive descent AST builder.
|
|
116
|
+
|
|
117
|
+
- The tokenizer uses explicit `m = regex.match(s)` calls rather than `String#gsub`, because `gsub` clobbers `$&` even with a String pattern argument, which interferes with the match data.
|
|
118
|
+
- The AST node types are `Comparison`, `Conjunction`, `Disjunction`, and `AttrPath` (all `Struct`s defined at the top of the file).
|
|
119
|
+
- `Parser.parse(str)` is the public entry point. It raises `Filter::Parser::ParseError` (a subclass of `DeviseScim::InvalidFilter`) on syntax errors.
|
|
120
|
+
|
|
121
|
+
**`lib/devise_scim/filter/arel_visitor.rb`** — maps AST nodes to Arel conditions.
|
|
122
|
+
|
|
123
|
+
- `SCIM_TO_AR` is the attribute mapping hash. To support a new SCIM attribute, add an entry: `"newScimAttr" => "ar_column_name"`.
|
|
124
|
+
- All conditions are built with Arel node methods (`col.eq`, `col.matches`, etc.) — never with string interpolation or `where("column = ?", val)`. This is a hard requirement enforced by Brakeman and code review.
|
|
125
|
+
- `ArelVisitor#apply(ast, scope)` returns a new scope with the Arel condition appended via `where`.
|
|
126
|
+
|
|
127
|
+
**Adding a new operator** (e.g., a hypothetical `contains-all`):
|
|
128
|
+
|
|
129
|
+
1. Add the operator string to `COMP_OPS` in `parser.rb`.
|
|
130
|
+
2. Add a tokenizer branch in the `elsif` chain that emits a `:op` token.
|
|
131
|
+
3. Add a `when` branch in `visit_comparison` in `arel_visitor.rb` that returns an Arel condition.
|
|
132
|
+
4. Add examples to both spec files.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Adding a New Endpoint
|
|
137
|
+
|
|
138
|
+
1. **Add route** in `lib/devise_scim/routing.rb` inside the appropriate `draw_*` method, or add a new private method and call it from `scim_for`.
|
|
139
|
+
|
|
140
|
+
2. **Add controller action** in `app/controllers/devise_scim/`. Inherit from `DeviseScim::ApplicationController` to get `tenant_scope`, `current_scim_tenant`, `render_scim`, and the error rescue chain.
|
|
141
|
+
|
|
142
|
+
3. **Add request spec** in `spec/requests/`. Test at minimum: unauthenticated request (401), valid request (2xx), and any error paths (404, 409, 400).
|
|
143
|
+
|
|
144
|
+
4. **Update discovery endpoints** if the new endpoint represents a new resource type or capability:
|
|
145
|
+
- `ServiceProviderConfig` (`app/controllers/devise_scim/service_provider_controller.rb`) — update `supported` flags for filter, sort, etag, etc.
|
|
146
|
+
- `Schemas` (`app/controllers/devise_scim/schemas_controller.rb`) — add a schema entry if introducing a new SCIM resource type.
|
|
147
|
+
- `ResourceTypes` (`app/controllers/devise_scim/resource_types_controller.rb`) — add an entry for the new endpoint.
|
|
148
|
+
|
|
149
|
+
Discovery endpoint responses are static; update them when the protocol surface changes, not just when implementation details change.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Security
|
|
154
|
+
|
|
155
|
+
**SQL injection prevention:** all database queries use Arel node methods or parameterized AR queries. The `ArelVisitor` never interpolates user input into strings. The `tenant_scope` helper builds conditions entirely from Arel table/column references. Never introduce `where("#{col} = ?", val)` or equivalent — use `@table[col_name].eq(val)` instead.
|
|
156
|
+
|
|
157
|
+
**Timing-safe token comparison:** single-tenant Bearer token authentication uses `ActiveSupport::SecurityUtils.secure_compare(raw, config.token)`, which prevents timing-oracle attacks. Do not replace this with `==`.
|
|
158
|
+
|
|
159
|
+
**BCrypt for token storage:** multi-tenant token digests are stored with `BCrypt::Password.create(raw)`. Do not store raw tokens. Do not use a weaker digest (SHA, MD5) as a "performance optimization".
|
|
160
|
+
|
|
161
|
+
**Brakeman must stay clean:** run `bundle exec brakeman --force --no-pager` before every PR. A new warning blocks the PR. If you believe a warning is a false positive, add a Brakeman ignore entry with a comment explaining why, and include it in the PR.
|
|
162
|
+
|
|
163
|
+
**Reporting security issues:** do not open a public GitHub issue for security vulnerabilities. Email the maintainer directly at `devise_scim@vinson.pro`. Include a description of the vulnerability, steps to reproduce, and your assessment of impact. You will receive a response within 72 hours.
|